diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 11a73d8..0000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-version: 2
-jobs:
- build:
- docker:
- - image: circleci/android:api-29
-
- working_directory: ~/repo
-
- environment:
- JVM_OPTS: -Xmx3200m
- TERM: dumb
-
- steps:
- - checkout
- - restore_cache:
- keys:
- - v1-dependencies-{{ checksum "build.gradle" }}
- - run: echo $ENCODED_KEYSTORE | base64 --decode >> ${HOME}/repo/keystore.jks
- - run: echo 'export KEYSTORE_FILE=${HOME}/repo/keystore.jks' >> $BASH_ENV
- - run: ./gradlew dependencies
- - save_cache:
- paths:
- - ~/.gradle
- key: v1-dependencies-{{ checksum "build.gradle" }}
- - run:
- name: Clean Project
- command: ./gradlew clean
- - run:
- name: Build Debug APK
- command: ./gradlew assembleDebug
- - run:
- name: Build Release APK
- command: ./gradlew assembleRelease
- - store_artifacts:
- path: app/build/outputs/
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 5911c33..1205bfc 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -27,13 +27,12 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- - Device: [e.g. Pixel XL]
- - OS: [e.g. Android 10]
- - Library version [e.g. 1.0.1]
-- Used Android SDK library versions [androidx.appcompat:appcompat:1.2.0-alpha03...]
-- Used gradle and wrapper version
-- Used tooling / Android Studio version
-- Other potentially conflicting libraries
+ - Library version: [e.g. 1.0.1]
+ - Kotlin version: [e.g. 2.4.0]
+ - Compose Multiplatform version: [e.g. 1.11.1]
+ - Platform(s) affected: [Android API level / iOS version / Desktop JVM]
+ - Device/simulator: [e.g. Pixel 8, iPhone 16 Simulator, macOS desktop]
+ - Reproduction steps/snippet: [minimal code snippet or sample project reproducing the issue]
**Additional context**
Add any other context about the problem here.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index f68e660..3fd77c0 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,4 +5,7 @@ updates:
schedule:
interval: daily
open-pull-requests-limit: 10
- target-branch: develop
+- package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..8738a8e
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,47 @@
+name: Build & Test
+
+on:
+ push:
+ branches: [ master, main ]
+ pull_request:
+ branches: [ master, main ]
+
+concurrency:
+ group: build-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build-android:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v5
+ with:
+ distribution: 'zulu'
+ java-version: '21'
+ - uses: gradle/actions/setup-gradle@v6
+ - name: Build library
+ run: ./gradlew :colorpicker:assemble
+ - name: Run tests
+ run: ./gradlew :colorpicker:allTests
+ - name: Build sample APK
+ run: ./gradlew :sample:androidApp:assembleDebug
+ - name: Upload sample APK
+ uses: actions/upload-artifact@v7
+ with:
+ name: sample-apk
+ path: sample/androidApp/build/outputs/apk/debug/*.apk
+
+ build-ios:
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v5
+ with:
+ distribution: 'zulu'
+ java-version: '21'
+ - uses: gradle/actions/setup-gradle@v6
+ - name: Build iOS framework
+ run: ./gradlew :colorpicker:linkDebugFrameworkIosSimulatorArm64
+ - name: Run iOS simulator tests
+ run: ./gradlew :colorpicker:iosSimulatorArm64Test
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..1d37c6d
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,24 @@
+name: Publish
+
+on:
+ push:
+ tags: [ 'v*' ]
+
+jobs:
+ publish:
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-java@v5
+ with:
+ distribution: 'zulu'
+ java-version: '21'
+ - uses: gradle/actions/setup-gradle@v6
+ - name: Publish to Maven Central
+ run: ./gradlew :colorpicker:publishAllPublicationsToSonatypeRepository
+ env:
+ OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }}
+ OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }}
+ SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }}
+ SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
+ SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
diff --git a/.gitignore b/.gitignore
index 4f7b373..d646d1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,11 @@
*.iml
.gradle
-/local.properties
+local.properties
.idea
.DS_Store
-/build
+build/
/captures
.externalNativeBuild
.cxx
+.claude/
+.kotlin/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9698e3c..dcc03a7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,16 +12,32 @@ In order to create a new feature request or bug report just file an issue. To fi
## Contributing
-We're maintaining two permanent protected branches: **master** and **develop**.
+**master** is the single permanent branch:
-- **master** is used for the current release and can't be changed (except non-library files) without a new release.
-- **develop** is used to prepare a new release.
+- Create feature branches off **master**.
+- Send pull requests targeting **master** (fork the repository if you don't have write access).
+- CI (`build.yml`) must pass before a pull request can be merged.
-If you would like to contribute code you can do so through GitHub by forking the repository and sending a pull request targeting the develop branch.
+Versioning is similar to Semantic Versioning.
-- The branching model is similar to git-flow.
-- Versioning is similar to Semantic Versioning.
-
-Our code style is defined via a hosted Intelliji xml config.
+Our code style is defined via the [`.editorconfig`](.editorconfig) file at the repository root; most IDEs (including IntelliJ IDEA and Android Studio) pick it up automatically.
When submitting code, please make every effort to follow existing conventions and code style in order to keep the code as readable as possible.
+
+## Testing
+
+Tests in `commonTest` are the primary suite. They run as JVM tests and as Android host (unit) tests, so no device, emulator, or simulator is required to validate most changes.
+
+## Building
+
+CI installs Zulu 21 to bootstrap Gradle; the daemon itself auto-provisions JetBrains Runtime 21 per `gradle/gradle-daemon-jvm.properties` (criteria: JETBRAINS 21). To change it run:
+
+```shell
+./gradlew updateDaemonJvm --toolchain-version=N --toolchain-vendor=VENDOR
+```
+
+Note that `gradle/gradle-daemon-jvm.properties` is generated by the `updateDaemonJvm` task — do not edit it by hand.
+
+## Release process
+
+Releases are tag-driven: maintainers tag a release (`v*`) and publish artifacts to Maven Central. The publishing pipeline is being reworked; details will be documented here once finalized.
diff --git a/README.md b/README.md
index e2b5e6f..a1d49f1 100644
--- a/README.md
+++ b/README.md
@@ -1,193 +1,354 @@
-# andColorPicker — Color Picker library for Android
+# andColorPicker
-:avocado: Handy, :snake: flexible, and :zap: lightning-fast Android color picker views and utilities.
+Multiplatform color picker library for Android, iOS, and Desktop (JVM), built with Compose Multiplatform and Material 3.
-
+## Features
-## :pill: Features
-
-- Clean, easy-to-use components and API
-- High performance
-- Material styling in mind
-- Standard Android SDK view family
-- Wide color models support
-- Tooling and utilities
+- Compose Multiplatform (Android, iOS, Desktop/JVM)
+- Material 3 theming via `ColorPickerDefaults`
+- HSL, RGB, CMYK, and LAB color models
- Alpha channel support
-- Cutting edge tech stack
-- Active development and support
+- Zero-drift editing: `ColorPickerState` keeps the authoritative color in the space you edited, so edit-in-X-read-X is always exact (conversions themselves are float-based)
+- Unidirectional data flow with `ColorPickerState`
+- Hex string parsing and formatting
+- Color picker dialog
+- Accessibility semantics and RTL layout support
-## :hammer: Setup
+## Setup
-Gradle dependency:
+```kotlin
+// build.gradle.kts
+implementation("codes.side:colorpicker:1.0.0")
+```
-```gradle
-implementation "codes.side:andcolorpicker:0.6.2"
+In a Kotlin Multiplatform project, add it to `commonMain`:
+
+```kotlin
+kotlin {
+ sourceSets {
+ commonMain.dependencies {
+ implementation("codes.side:colorpicker:1.0.0")
+ }
+ }
+}
```
-## :art: Picker types
+Published targets: `android`, `jvm`, `iosArm64`, `iosSimulatorArm64`.
-### HSL (hue, saturation, lightness)
+## Quick Start
-- *Add color model description*
+```kotlin
+@Composable
+fun MyScreen() {
+ val state = rememberColorPickerState(
+ initialColor = HslColor(hue = 200f, saturation = 0.8f, lightness = 0.5f)
+ )
-
+ Column {
+ HslColorPicker(state = state)
+ ColorSwatch(
+ color = state.hslColor.toComposeColor(),
+ modifier = Modifier.size(48.dp)
+ )
+ }
+}
+```
+
+## Color Models
-#### Layout XML Snippet
+All color models use **Float** for full precision. Integer accessors and factories are provided for convenience.
-Basic HSL components:
-```xml
-
+### HSL
+
+```kotlin
+val color = HslColor(hue = 210f, saturation = 0.8f, lightness = 0.5f, alpha = 1f)
+// hue: [0, 360], saturation/lightness/alpha: [0, 1]
+
+// Integer accessors
+color.intHue // 210
+color.intSaturation // 80
+color.intLightness // 50
+color.intAlpha // 255
+
+// From integers
+HslColor.fromInt(hue = 210, saturation = 80, lightness = 50, alpha = 255)
```
-Supported `hslMode` values:
-- `hue` (default)
-- `saturation`
-- `lightness`
+### RGB
+
+```kotlin
+val color = RgbColor(red = 0.2f, green = 0.5f, blue = 0.8f, alpha = 1f)
+// All components: [0, 1]
-Supported `hslColoringMode` values:
-- `pure` (default)
-- `output`
+color.intRed // 51
+color.intGreen // 128
+color.intBlue // 204
-Alpha component:
-```xml
-
+RgbColor.fromInt(red = 51, green = 128, blue = 204)
```
-#### Kotlin Snippet
+### CMYK
+
```kotlin
-// Configure color model programmatically
-hueSeekBar.mode = Mode.MODE_HUE // Mode.MODE_SATURATION, Mode.MODE_LIGHTNESS
+val color = CmykColor(cyan = 0.3f, magenta = 0.6f, yellow = 0.1f, key = 0.2f)
+// All components: [0, 1]
-// Configure coloring mode programmatically
-hueSeekBar.coloringMode = ColoringMode.PURE_COLOR // ColoringMode.OUTPUT_COLOR
+CmykColor.fromInt(cyan = 30, magenta = 60, yellow = 10, key = 20)
+```
-// Group pickers with PickerGroup to automatically synchronize color across them
-val group = PickerGroup().also {
- it.registerPickers(
- hueSeekBar,
- saturationSeekBar,
- lightnessSeekBar,
- alphaSeekBar
- )
-}
+### LAB
-// Get current color immediately
-Log.d(
- TAG,
- "Current color is ${hueSeekBar.pickedColor}"
-)
+```kotlin
+val color = LabColor(l = 53.23f, a = 80.11f, b = 67.22f)
+// l: [0, 100], a: [-128, 127], b: [-128, 127], alpha: [0, 1]
-// Listen individual pickers or groups for changes
-group.addListener(
- object : HSLColorPickerSeekBar.DefaultOnColorPickListener() {
- override fun onColorChanged(
- picker: ColorSeekBar,
- color: IntegerHSLColor,
- value: Int
- ) {
- Log.d(
- TAG,
- "$color picked"
- )
- swatchView.setSwatchColor(
- color
- )
- }
- }
-)
+LabColor.fromInt(l = 53, a = 80, b = 67)
+```
-// Set desired color programmatically
-group.setColor(
- IntegerHSLColor().also {
- it.setFromColorInt(
- Color.rgb(
- 28,
- 84,
- 187
- )
- )
- }
-)
+## Conversions
+
+Conversions are extension functions. They operate on floats end to end — nothing is quantized to integers until you explicitly ask for an ARGB `Int` or a hex string. Like any color space conversion, a cross-space round trip is not guaranteed to be bit-exact; the zero-drift guarantee comes from `ColorPickerState`'s origin tracking (see [Architecture](#architecture-zero-drift-color-conversions)).
-// Set color components programmatically
-hueSeekBar.progress = 50
+```kotlin
+val hsl = HslColor(hue = 0f, saturation = 1f, lightness = 0.5f)
+val rgb = hsl.toRgb()
+val cmyk = rgb.toCmyk()
+val lab = rgb.toLab()
+val argb = rgb.toArgbInt()
+
+// Compose interop, both ways
+val composeColor: Color = hsl.toComposeColor()
+val backToHsl: HslColor = composeColor.toHslColor()
+val backToRgb: RgbColor = composeColor.toRgbColor()
+val backToCmyk: CmykColor = composeColor.toCmykColor()
+val backToLab: LabColor = composeColor.toLabColor()
```
-### RGB (red, green, blue)
+### Hex strings
-
+```kotlin
+val rgb = RgbColor(red = 0.2f, green = 0.5f, blue = 0.8f)
+
+// Formatting: any PickerColor or packed ARGB Int
+rgb.toHexString() // "#FF3380CC" (#AARRGGBB, alpha first)
+rgb.toHexString(includeAlpha = false) // "#3380CC"
+0xFF3380CC.toInt().toHexColorString() // "#FF3380CC"
+
+// Parsing: accepts #RGB, #RRGGBB, and #AARRGGBB; the '#' is optional
+"#3380CC".toRgbColorOrNull() // RgbColor, alpha defaults to FF
+"#ABC".toRgbColorOrNull() // shorthand, expands to #AABBCC
+"not a color".toRgbColorOrNull() // null, never throws
+"#3380CC".toRgbColor() // throws IllegalArgumentException on invalid input
+```
-#### Properties
+## Color Picker Components
-- View name: ```RGBColorPickerSeekBar```
-- ```app:rgbMode``` for RGB component selection
+### Full Pickers
-### LAB
+Each color model has a ready-made picker that stacks its channel sliders (plus an optional alpha slider):
+
+```kotlin
+val state = rememberColorPickerState()
-
+HslColorPicker(
+ state = state,
+ showAlpha = true,
+ coloringMode = ColoringMode.Independent, // or Contextual
+)
-#### Properties
+RgbColorPicker(state = state, showAlpha = true)
+CmykColorPicker(state = state, showAlpha = true)
+LabColorPicker(state = state, showAlpha = true)
+```
-- View name: ```LABColorPickerSeekBar```
-- ```app:labMode``` for LAB component selection
+`ColoringMode` controls the slider gradients: `Independent` shows each channel's full range regardless of the other channels, `Contextual` previews the actual resulting color at each position.
-### CMYK (cyan, magenta, yellow, key)
+### Individual Sliders
-
+Every channel is available as a standalone slider. Compose any subset against a shared state:
-#### Properties
+```kotlin
+// HSL
+HueSlider(state = state)
+SaturationSlider(state = state)
+LightnessSlider(state = state)
+
+// RGB
+RedSlider(state = state)
+GreenSlider(state = state)
+BlueSlider(state = state)
+
+// CMYK
+CyanSlider(state = state)
+MagentaSlider(state = state)
+YellowSlider(state = state)
+KeySlider(state = state)
+
+// LAB
+LightnessLabSlider(state = state)
+LabASlider(state = state)
+LabBSlider(state = state)
+
+// Alpha (works with any origin space)
+AlphaSlider(state = state)
+```
-- View name: ```CMYKColorPickerSeekBar```
-- ```app:cmykMode``` for CMYK component selection
-- ```app:cmykColoringMode``` for coloring mode selection
+Sliders expose slots and semantics for customization:
-Supported `cmykMode` values:
-- `cyan` (default)
-- `magenta`
-- `yellow`
-- `black`
+```kotlin
+HueSlider(
+ state = state,
+ label = { SliderLabel("Hue") }, // leading label slot (null to hide)
+ valueLabel = { SliderValueLabel("200°") }, // trailing value slot (null to hide)
+ semanticLabel = "Hue", // accessibility label
+ semanticValueText = "200°", // accessibility value announcement
+)
+```
-Supported `cmykColoringMode` values:
-- `pure` (default)
-- `output`
+### Color Swatch
-### Swatches
+Renders a color over a transparency checkerboard:
-SwatchView component:
-```xml
-
+```kotlin
+ColorSwatch(
+ color = state.hslColor.toComposeColor(),
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ contentDescription = "Selected color",
+)
```
-#### Kotlin Snippet:
+### Dialog
+
+A Material 3 `AlertDialog` with an HSL picker and a live swatch. Callbacks come first; everything else has defaults:
+
```kotlin
-swatchView.setSwatchPatternTint(
- Color.LTGRAY
+ColorPickerDialog(
+ onColorSelected = { hsl -> /* confirmed color */ },
+ onDismiss = { /* close */ },
+ initialColor = HslColor(hue = 200f, saturation = 0.8f, lightness = 0.5f),
+ title = "Pick a Color",
+ confirmText = "Select",
+ dismissText = "Cancel",
+ showAlpha = true,
)
+```
-swatchView.setSwatchColor(
- IntegerHSLColor().also {
- it.setFromColorInt(
- ColorUtils.setAlphaComponent(
- Color.MAGENTA,
- 128
- )
- )
- }
+In-progress edits inside the dialog survive configuration changes; passing a new `initialColor` resets the picker.
+
+### Theming
+
+All pickers and sliders accept `colors` and `shapes` built with `ColorPickerDefaults`, which derive from `MaterialTheme` by default:
+
+```kotlin
+HslColorPicker(
+ state = state,
+ colors = ColorPickerDefaults.colors(
+ checkerboardLight = Color.White,
+ checkerboardDark = Color.LightGray,
+ ),
+ shapes = ColorPickerDefaults.shapes(
+ trackShape = RoundedCornerShape(4.dp),
+ swatchShape = RoundedCornerShape(8.dp),
+ ),
)
```
-## :memo: License
+## State Management
+
+`ColorPickerState` is the single source of truth. It reads and writes each color space natively, with no round-trip conversions.
+
+```kotlin
+val state = rememberColorPickerState()
+
+// Read any color space (derived from the authoritative color)
+state.hslColor
+state.rgbColor
+state.cmykColor
+state.labColor
+state.argbInt
+state.pickerColor // the authoritative color, in whichever space was last written
+
+// Per-channel updates (NaN ignored, values clamped)
+state.updateHue(180f)
+state.updateSaturation(0.5f)
+state.updateLightness(0.5f)
+state.updateRed(1f)
+state.updateGreen(0f)
+state.updateBlue(0f)
+state.updateCyan(0.3f)
+state.updateMagenta(0.6f)
+state.updateYellow(0.1f)
+state.updateKey(0.2f)
+state.updateLabLightness(50f)
+state.updateLabA(20f)
+state.updateLabB(-30f)
+state.updateAlpha(0.5f) // keeps the current origin space
+
+// Whole-color updates (the written space becomes the origin)
+state.updateFromHsl(HslColor(hue = 0f, saturation = 1f, lightness = 0.5f))
+state.updateFromRgb(RgbColor(1f, 0f, 0f))
+state.updateFromCmyk(cmykColor)
+state.updateFromLab(labColor)
+state.updateFromArgbInt(0xFFFF0000.toInt())
+
+// True while the user is dragging a slider
+state.isInteracting
+```
+
+`ColorPickerState` has a public constructor, so it can also be created and held outside of composition (e.g. in a ViewModel).
+
+Use `rememberSaveableColorPickerState()` to keep the state across configuration changes and process death on platforms that provide saved-instance-state support (primarily Android). On other platforms it behaves like `rememberColorPickerState` within the composition. The saver preserves the authoritative color space, not just the visible color.
+
+## Architecture: Zero-Drift Color Conversions
+
+Color space conversions are inherently lossy when values are quantized to integers, and even with floats, transcendental functions (used in LAB) introduce IEEE 754 rounding errors. Industry-standard tools (Photoshop, CSS Color Level 4, Sass) solve this the same way we do:
+
+**Store colors in their authored color space. Convert forward only. Never convert back.**
+
+`ColorPickerState` tracks which color space was last written to (the *origin*). When you read a different space, it converts forward once from the origin. The origin value is never re-derived from a conversion.
+
+```
+User drags Red slider
+ -> the authoritative color is written as RGB (origin = RGB, zero conversions)
+ -> UI reads hslColor -> converts RGB->HSL once (forward only)
+ -> UI reads rgbColor -> returns the authoritative RGB value as-is (zero conversions)
+```
+
+This means:
+- Editing in RGB and reading back RGB produces **the exact original value**
+- Editing in HSL and reading back HSL produces **the exact original value**
+- Cross-space reads involve a single forward conversion, never a round-trip
+- No precision loss accumulates over time, regardless of how many edits are made
+
+For more details, see:
+- [CSS Color Module Level 4](https://www.w3.org/TR/css-color-4/) -- the W3C spec mandates the same approach
+- [Sass Color Spaces](https://css.oddbird.net/sass/color-spaces/proposal/) -- stores colors in their original space
+
+## Migrating from andcolorpicker (0.6.x)
+
+The View-based `codes.side:andcolorpicker` artifact (XML `HSLColorPickerSeekBar` and friends) is discontinued. This library is a full Compose Multiplatform rewrite published under new coordinates:
+
+```diff
+- implementation("codes.side:andcolorpicker:0.6.2")
++ implementation("codes.side:colorpicker:1.0.0")
+```
+
+There is no 1:1 API mapping — migrate by concept:
+
+| andcolorpicker (View-based) | colorpicker (Compose) |
+|----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `HSLColorPickerSeekBar` (`hslMode` = hue/saturation/lightness) | `HueSlider` / `SaturationSlider` / `LightnessSlider`, or `HslColorPicker` for all three |
+| `RGBColorPickerSeekBar` | `RedSlider` / `GreenSlider` / `BlueSlider`, or `RgbColorPicker` |
+| `CMYKColorPickerSeekBar` | `CyanSlider` / `MagentaSlider` / `YellowSlider` / `KeySlider`, or `CmykColorPicker` |
+| `LABColorPickerSeekBar` | `LightnessLabSlider` / `LabASlider` / `LabBSlider`, or `LabColorPicker` |
+| `HSLAlphaColorPickerSeekBar` | `AlphaSlider` |
+| `PickerGroup` + `registerPickers` | Pass one `ColorPickerState` to every component — they stay in sync automatically |
+| `SwatchView` | `ColorSwatch` |
+| `OnColorPickListener` / `addListener` | Read `state.hslColor` (or any other space) — it is Compose snapshot state, so composition recomposes automatically; use `snapshotFlow` outside composition |
+| `IntegerHSLColor` and friends | `HslColor`, `RgbColor`, `CmykColor`, `LabColor` (float-based, with `fromInt` factories) |
+| `hslColoringMode` = `pure` / `output` | `ColoringMode.Independent` / `ColoringMode.Contextual` |
+
+## License
```
Copyright 2020 Illia Achour
diff --git a/andcolorpicker/.gitignore b/andcolorpicker/.gitignore
deleted file mode 100644
index 796b96d..0000000
--- a/andcolorpicker/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build
diff --git a/andcolorpicker/build.gradle b/andcolorpicker/build.gradle
deleted file mode 100644
index 6c6776f..0000000
--- a/andcolorpicker/build.gradle
+++ /dev/null
@@ -1,52 +0,0 @@
-plugins {
- id "com.github.ben-manes.versions" version "0.38.0"
-}
-
-apply plugin: "com.android.library"
-apply plugin: "kotlin-android"
-apply plugin: "kotlin-android-extensions"
-
-ext {
- PUBLISH_GROUP_ID = "codes.side"
- PUBLISH_VERSION = "0.6.2"
- PUBLISH_ARTIFACT_ID = "andcolorpicker"
-}
-
-apply from: "${rootProject.projectDir}/scripts/publish-mavencentral.gradle"
-
-android {
- compileSdkVersion 30
- buildToolsVersion "29.0.3"
-
- defaultConfig {
- minSdkVersion 19
- targetSdkVersion 30
- versionCode 8
- versionName "0.6.2"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- consumerProguardFiles "consumer-rules.pro"
- }
- lintOptions {
- abortOnError false
- }
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
- }
- }
-}
-
-dependencies {
- implementation fileTree(dir: "libs", include: ["*.jar"])
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
-
- implementation "androidx.appcompat:appcompat:1.3.0-rc01"
- implementation "androidx.core:core-ktx:1.6.0-alpha02"
- implementation "androidx.constraintlayout:constraintlayout:2.0.4"
-
- testImplementation "junit:junit:4.13.2"
- androidTestImplementation "androidx.test.ext:junit:1.1.2"
- androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0"
-}
diff --git a/andcolorpicker/consumer-rules.pro b/andcolorpicker/consumer-rules.pro
deleted file mode 100644
index e69de29..0000000
diff --git a/andcolorpicker/proguard-rules.pro b/andcolorpicker/proguard-rules.pro
deleted file mode 100644
index f1b4245..0000000
--- a/andcolorpicker/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
diff --git a/andcolorpicker/src/androidTest/java/codes/side/andcolorpicker/ExampleInstrumentedTest.kt b/andcolorpicker/src/androidTest/java/codes/side/andcolorpicker/ExampleInstrumentedTest.kt
deleted file mode 100644
index 0802e48..0000000
--- a/andcolorpicker/src/androidTest/java/codes/side/andcolorpicker/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package codes.side.andcolorpicker
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.assertEquals
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation()
- .targetContext
- assertEquals(
- "codes.side.andcolorpicker.test",
- appContext.packageName
- )
- }
-}
diff --git a/andcolorpicker/src/main/AndroidManifest.xml b/andcolorpicker/src/main/AndroidManifest.xml
deleted file mode 100644
index d3b1905..0000000
--- a/andcolorpicker/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/alpha/AlphaColorPickerSeekBar.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/alpha/AlphaColorPickerSeekBar.kt
deleted file mode 100644
index ef25f09..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/alpha/AlphaColorPickerSeekBar.kt
+++ /dev/null
@@ -1,116 +0,0 @@
-package codes.side.andcolorpicker.alpha
-
-import android.content.Context
-import android.graphics.BitmapFactory
-import android.graphics.Shader
-import android.graphics.drawable.BitmapDrawable
-import android.graphics.drawable.Drawable
-import android.graphics.drawable.GradientDrawable
-import android.graphics.drawable.LayerDrawable
-import android.util.AttributeSet
-import codes.side.andcolorpicker.R
-import codes.side.andcolorpicker.model.Color
-import codes.side.andcolorpicker.model.factory.ColorFactory
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-
-// TODO: Minimize resource reads
-// TODO: Make bg_transparency_pattern programmatic
-// TODO: Think on making that non-abstract
-// TODO: Think on adding AlphaColor layer
-abstract class AlphaColorPickerSeekBar @JvmOverloads constructor(
- colorFactory: ColorFactory,
- context: Context,
- attrs: AttributeSet? = null,
- defStyle: Int = R.attr.seekBarStyle
-) :
- ColorSeekBar(
- colorFactory,
- context,
- attrs,
- defStyle
- ) {
-
- // TODO: Handle rounded corners
- override fun onSetupProgressDrawableLayers(layers: Array): Array {
- val layerList = layers.toMutableList()
-
- val options = BitmapFactory.Options().also {
- it.inMutable = true
- }
- val bitmap = BitmapFactory.decodeResource(
- resources,
- R.drawable.bg_transparency_tile_horizontal,
- options
- )
-
- val patternDrawable = BitmapDrawable(
- resources,
- bitmap
- ).also {
- it.setTileModeXY(
- Shader.TileMode.REPEAT,
- Shader.TileMode.CLAMP
- )
- }
-
- //val id = ScaleDrawable(
- // patternDrawable,
- // Gravity.FILL_HORIZONTAL,
- // 1f,
- // 0.5f
- //)
-
- layerList.add(
- patternDrawable
- )
-
- layerList.add(
- GradientDrawable().also {
- it.orientation = GradientDrawable.Orientation.LEFT_RIGHT
- it.cornerRadius =
- resources.getDimensionPixelOffset(R.dimen.acp_seek_progress_corner_radius)
- .toFloat()
- it.shape = GradientDrawable.RECTANGLE
- // TODO: Make stroke configurable
- //it.setStroke(
- // 4,
- // Color.rgb(
- // 192,
- // 192,
- // 192
- // )
- //)
- }
- )
-
- return layerList.toTypedArray()
- }
-
- override fun onRefreshProgressDrawable(progressDrawable: LayerDrawable) {
- (progressDrawable.getDrawable(1) as GradientDrawable).colors =
- intArrayOf(
- android.graphics.Color.TRANSPARENT,
- colorConverter.convertToOpaqueColorInt(internalPickedColor)
- )
- }
-
- override fun onRefreshThumb(thumbColoringDrawables: Set) {
- thumbColoringDrawables.forEach {
- when (it) {
- is GradientDrawable -> {
- paintThumbStroke(it)
- }
- is LayerDrawable -> {
- paintThumbStroke(it.getDrawable(0) as GradientDrawable)
- }
- }
- }
- }
-
- private fun paintThumbStroke(drawable: GradientDrawable) {
- drawable.setStroke(
- thumbStrokeWidthPx,
- colorConverter.convertToColorInt(internalPickedColor)
- )
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/alpha/HSLAlphaColorPickerSeekBar.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/alpha/HSLAlphaColorPickerSeekBar.kt
deleted file mode 100644
index 0ec1d35..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/alpha/HSLAlphaColorPickerSeekBar.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package codes.side.andcolorpicker.alpha
-
-import android.content.Context
-import android.util.AttributeSet
-import codes.side.andcolorpicker.R
-import codes.side.andcolorpicker.converter.IntegerHSLColorConverter
-import codes.side.andcolorpicker.model.IntegerHSLColor
-import codes.side.andcolorpicker.model.factory.HSLColorFactory
-
-// TODO: Add modes support
-class HSLAlphaColorPickerSeekBar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyle: Int = R.attr.seekBarStyle
-) :
- AlphaColorPickerSeekBar(
- HSLColorFactory(),
- context,
- attrs,
- defStyle
- ) {
-
- private var isInitialized = false
-
- override val colorConverter: IntegerHSLColorConverter
- get() = super.colorConverter as IntegerHSLColorConverter
-
- init {
- refreshProperties()
- isInitialized = true
- }
-
- override fun setMax(max: Int) {
- if (isInitialized && max != IntegerHSLColor.Component.A.maxValue) {
- throw IllegalArgumentException("Current mode supports ${IntegerHSLColor.Component.A.maxValue} max value only, was $max")
- }
- super.setMax(max)
- }
-
- override fun onUpdateColorFrom(color: IntegerHSLColor, value: IntegerHSLColor) {
- color.setFrom(value)
- }
-
- override fun onRefreshProperties() {
- max = IntegerHSLColor.Component.A.maxValue
- }
-
- override fun onRefreshProgressFromColor(color: IntegerHSLColor): Int {
- return color.intA
- }
-
- override fun onRefreshColorFromProgress(color: IntegerHSLColor, progress: Int): Boolean {
- val currentA = internalPickedColor.intA
- return if (currentA != progress) {
- internalPickedColor.intA = progress
- true
- } else {
- false
- }
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/cmyk/CMYKColorPickerSeekBar.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/cmyk/CMYKColorPickerSeekBar.kt
deleted file mode 100644
index 9c8b230..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/cmyk/CMYKColorPickerSeekBar.kt
+++ /dev/null
@@ -1,326 +0,0 @@
-package codes.side.andcolorpicker.cmyk
-
-import android.content.Context
-import android.graphics.Color
-import android.graphics.drawable.Drawable
-import android.graphics.drawable.GradientDrawable
-import android.graphics.drawable.LayerDrawable
-import android.util.AttributeSet
-import androidx.core.graphics.ColorUtils
-import codes.side.andcolorpicker.R
-import codes.side.andcolorpicker.converter.IntegerCMYKColorConverter
-import codes.side.andcolorpicker.model.IntegerCMYKColor
-import codes.side.andcolorpicker.model.factory.CMYKColorFactory
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-import codes.side.andcolorpicker.view.picker.GradientColorSeekBar
-
-class CMYKColorPickerSeekBar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyle: Int = R.attr.seekBarStyle
-) :
- GradientColorSeekBar(
- CMYKColorFactory(),
- context,
- attrs,
- defStyle
- ) {
-
- override val colorConverter: IntegerCMYKColorConverter
- get() = super.colorConverter as IntegerCMYKColorConverter
-
- private var modeInitialized = false
- private var _mode: Mode? = null
- var mode: Mode
- get() {
- return requireNotNull(_mode) { "Mode is not initialized yet" }
- }
- set(value) {
- modeInitialized = true
- if (_mode == value) {
- return
- }
- _mode = value
- refreshProperties()
- refreshProgressFromCurrentColor()
- refreshProgressDrawable()
- refreshThumb()
- }
-
- private var coloringModeInitialized = false
- private var _coloringMode: ColoringMode? = null
- var coloringMode: ColoringMode
- get() {
- return requireNotNull(_coloringMode) { "Coloring mode is not initialized yet" }
- }
- set(value) {
- coloringModeInitialized = true
- if (_coloringMode == value) {
- return
- }
- _coloringMode = value
- refreshProgressDrawable()
- refreshThumb()
- }
-
- init {
- init(attrs)
- }
-
- private fun init(attrs: AttributeSet? = null) {
- val typedArray = context.theme.obtainStyledAttributes(
- attrs,
- R.styleable.CMYKColorPickerSeekBar,
- 0,
- 0
- )
-
- mode = Mode.values()[typedArray.getInteger(
- R.styleable.CMYKColorPickerSeekBar_cmykMode,
- DEFAULT_MODE.ordinal
- )]
- coloringMode = ColoringMode.values()[typedArray.getInteger(
- R.styleable.CMYKColorPickerSeekBar_cmykColoringMode,
- DEFAULT_COLORING_MODE.ordinal
- )]
-
- typedArray.recycle()
- }
-
- override fun setMax(max: Int) {
- if (modeInitialized && max != mode.absoluteProgress) {
- throw IllegalArgumentException("Current mode supports ${mode.absoluteProgress} max value only, was $max")
- }
- super.setMax(max)
- }
-
- override fun onUpdateColorFrom(color: IntegerCMYKColor, value: IntegerCMYKColor) {
- color.setFrom(value)
- }
-
- override fun onRefreshProperties() {
- if (!modeInitialized) {
- return
- }
- max = mode.absoluteProgress
- }
-
- override fun onRefreshProgressFromColor(color: IntegerCMYKColor): Int? {
- if (!modeInitialized) {
- return null
- }
-
- return -mode.minProgress + when (mode) {
- Mode.MODE_C -> {
- internalPickedColor.intC
- }
- Mode.MODE_M -> {
- internalPickedColor.intM
- }
- Mode.MODE_Y -> {
- internalPickedColor.intY
- }
- Mode.MODE_K -> {
- internalPickedColor.intK
- }
- }
- }
-
- override fun onRefreshProgressDrawable(progressDrawable: LayerDrawable) {
- if (!coloringModeInitialized || !modeInitialized) {
- return
- }
-
- (progressDrawable.getDrawable(0) as GradientDrawable).colors = when (mode) {
- Mode.MODE_C -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> Mode.MODE_C.checkpoints
- ColoringMode.OUTPUT_COLOR -> TODO()
- }
- }
- Mode.MODE_M -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> Mode.MODE_M.checkpoints
- ColoringMode.OUTPUT_COLOR -> TODO()
- }
- }
- Mode.MODE_Y -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> Mode.MODE_Y.checkpoints
- ColoringMode.OUTPUT_COLOR -> TODO()
- }
- }
- Mode.MODE_K -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> Mode.MODE_K.checkpoints
- ColoringMode.OUTPUT_COLOR -> TODO()
- }
- }
- }
- }
-
- override fun onRefreshThumb(thumbColoringDrawables: Set) {
- thumbColoringDrawables.forEach {
- when (it) {
- is GradientDrawable -> {
- paintThumbStroke(it)
- }
- is LayerDrawable -> {
- paintThumbStroke(it.getDrawable(0) as GradientDrawable)
- }
- }
- }
- }
-
- override fun onRefreshColorFromProgress(color: IntegerCMYKColor, progress: Int): Boolean {
- if (!modeInitialized) {
- return false
- }
-
- val unmaskedProgress = mode.minProgress + progress
- return when (mode) {
- Mode.MODE_C -> {
- val currentH = color.intC
- if (currentH != unmaskedProgress) {
- color.intC = unmaskedProgress
- true
- } else {
- false
- }
- }
- Mode.MODE_M -> {
- val currentS = color.intM
- if (currentS != unmaskedProgress) {
- color.intM = unmaskedProgress
- true
- } else {
- false
- }
- }
- Mode.MODE_Y -> {
- val currentL = color.intY
- if (currentL != unmaskedProgress) {
- color.intY = unmaskedProgress
- true
- } else {
- false
- }
- }
- Mode.MODE_K -> {
- val currentL = color.intK
- if (currentL != unmaskedProgress) {
- color.intK = unmaskedProgress
- true
- } else {
- false
- }
- }
- }
- }
-
- // TODO: Refactor
- private fun paintThumbStroke(drawable: GradientDrawable) {
- if (!coloringModeInitialized || !modeInitialized) {
- return
- }
-
- val currentProgress = progress
- drawable.setStroke(
- thumbStrokeWidthPx,
- when (mode) {
- Mode.MODE_C -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> ColorUtils.blendARGB(
- Color.WHITE,
- Color.CYAN,
- currentProgress.coerceAtLeast(COERCE_AT_LEAST_COMPONENT) / mode.maxProgress.toFloat()
- )
- ColoringMode.OUTPUT_COLOR -> TODO()
- }
- }
- Mode.MODE_M -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> ColorUtils.blendARGB(
- Color.WHITE,
- Color.MAGENTA,
- currentProgress.coerceAtLeast(COERCE_AT_LEAST_COMPONENT) / mode.maxProgress.toFloat()
- )
- ColoringMode.OUTPUT_COLOR -> TODO()
- }
- }
- Mode.MODE_Y -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> ColorUtils.blendARGB(
- Color.WHITE,
- Color.YELLOW,
- currentProgress.coerceAtLeast(COERCE_AT_LEAST_COMPONENT) / mode.maxProgress.toFloat()
- )
- ColoringMode.OUTPUT_COLOR -> TODO()
- }
- }
- Mode.MODE_K -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> ColorUtils.blendARGB(
- Color.WHITE,
- Color.BLACK,
- currentProgress.coerceAtLeast(COERCE_AT_LEAST_COMPONENT) / mode.maxProgress.toFloat()
- )
- ColoringMode.OUTPUT_COLOR -> TODO()
- }
- }
- }
- )
- }
-
- enum class ColoringMode {
- PURE_COLOR,
- OUTPUT_COLOR
- }
-
- enum class Mode(
- override val minProgress: Int,
- override val maxProgress: Int,
- val checkpoints: IntArray
- ) : ColorSeekBar.Mode {
- MODE_C(
- IntegerCMYKColor.Component.C.minValue,
- IntegerCMYKColor.Component.C.maxValue,
- intArrayOf(
- Color.WHITE,
- Color.CYAN
- )
- ),
- MODE_M(
- IntegerCMYKColor.Component.M.minValue,
- IntegerCMYKColor.Component.M.maxValue,
- intArrayOf(
- Color.WHITE,
- Color.MAGENTA
- )
- ),
- MODE_Y(
- IntegerCMYKColor.Component.Y.minValue,
- IntegerCMYKColor.Component.Y.maxValue,
- intArrayOf(
- Color.WHITE,
- Color.YELLOW
- )
- ),
- MODE_K(
- IntegerCMYKColor.Component.K.minValue,
- IntegerCMYKColor.Component.K.maxValue,
- intArrayOf(
- Color.WHITE,
- Color.BLACK
- )
- ),
- }
-
- companion object {
- private val DEFAULT_MODE = Mode.MODE_C
- private val DEFAULT_COLORING_MODE = ColoringMode.PURE_COLOR
-
- // TODO: Make configurable
- private const val COERCE_AT_LEAST_COMPONENT = 15
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/ColorConverter.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/ColorConverter.kt
deleted file mode 100644
index 10931e7..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/ColorConverter.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package codes.side.andcolorpicker.converter
-
-import androidx.annotation.ColorInt
-import codes.side.andcolorpicker.model.Color
-
-interface ColorConverter {
-
- @ColorInt
- fun convertToOpaqueColorInt(color: Color): Int
-
- @ColorInt
- fun convertToColorInt(color: Color): Int
-
- @ColorInt
- fun convertToPureHueColorInt(color: Color): Int
-
- fun setFromColorInt(color: Color, @ColorInt value: Int)
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/ColorConverterExtensions.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/ColorConverterExtensions.kt
deleted file mode 100644
index 35df0ea..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/ColorConverterExtensions.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package codes.side.andcolorpicker.converter
-
-import androidx.annotation.ColorInt
-import codes.side.andcolorpicker.model.Color
-
-@ColorInt
-fun Color.toColorInt(): Int {
- return ColorConverterHub.getConverterByKey(colorKey).convertToColorInt(this)
-}
-
-@ColorInt
-fun Color.toOpaqueColorInt(): Int {
- return ColorConverterHub.getConverterByKey(colorKey).convertToOpaqueColorInt(this)
-}
-
-@ColorInt
-fun Color.toPureHueColorInt(): Int {
- return ColorConverterHub.getConverterByKey(colorKey).convertToPureHueColorInt(this)
-}
-
-fun Color.getRInt(): Int {
- return android.graphics.Color.red(toColorInt())
-}
-
-fun Color.getGInt(): Int {
- return android.graphics.Color.green(toColorInt())
-}
-
-fun Color.getBInt(): Int {
- return android.graphics.Color.blue(toColorInt())
-}
-
-@ColorInt
-fun Color.toContrastColor(mode: ContrastColorAlphaMode = ContrastColorAlphaMode.NONE): Int {
- val black = (getRInt() * 0.299f + getGInt() * 0.587f + getBInt() * 0.114f > 186)
- val highAlphaContrastColor = if (black) {
- android.graphics.Color.BLACK
- } else {
- android.graphics.Color.WHITE
- }
- return when (mode) {
- ContrastColorAlphaMode.NONE -> {
- highAlphaContrastColor
- }
- ContrastColorAlphaMode.LIGHT_BACKGROUND -> {
- if (alpha < 0.5f) {
- android.graphics.Color.BLACK
- } else {
- highAlphaContrastColor
- }
- }
- ContrastColorAlphaMode.DARK_BACKGROUND -> {
- if (alpha < 0.5f) {
- android.graphics.Color.WHITE
- } else {
- highAlphaContrastColor
- }
- }
- }
-}
-
-fun Color.setFromColorInt(@ColorInt value: Int) {
- ColorConverterHub.getConverterByKey(colorKey).setFromColorInt(
- this,
- value
- )
-}
-
-enum class ContrastColorAlphaMode {
- NONE,
- LIGHT_BACKGROUND,
- DARK_BACKGROUND
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/ColorConverterHub.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/ColorConverterHub.kt
deleted file mode 100644
index 9cac568..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/ColorConverterHub.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package codes.side.andcolorpicker.converter
-
-import codes.side.andcolorpicker.model.ColorKey
-
-object ColorConverterHub {
-
- private val map = hashMapOf()
-
- init {
- registerConverter(
- ColorKey.HSL,
- IntegerHSLColorConverter()
- )
- registerConverter(
- ColorKey.CMYK,
- IntegerCMYKColorConverter()
- )
- registerConverter(
- ColorKey.RGB,
- IntegerRGBColorConverter()
- )
- registerConverter(
- ColorKey.LAB,
- IntegerLABColorConverter()
- )
- }
-
- @Suppress("MemberVisibilityCanBePrivate")
- fun getConverterByKey(key: ColorKey): ColorConverter {
- return requireNotNull(map[key])
- }
-
- @Suppress("MemberVisibilityCanBePrivate")
- fun registerConverter(key: ColorKey, converter: ColorConverter) {
- map[key] = converter
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerCMYKColorConverter.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerCMYKColorConverter.kt
deleted file mode 100644
index daf4526..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerCMYKColorConverter.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package codes.side.andcolorpicker.converter
-
-import codes.side.andcolorpicker.model.Color
-import codes.side.andcolorpicker.model.IntegerCMYKColor
-
-class IntegerCMYKColorConverter : ColorConverter {
-
- override fun convertToOpaqueColorInt(color: Color): Int {
- TODO("Not yet implemented")
- }
-
- override fun convertToColorInt(color: Color): Int {
- require(color is IntegerCMYKColor) { "Unsupported color type supplied" }
-
- val r = 255f * (1f - color.floatC) * (1f - color.floatK)
- val g = 255f * (1f - color.floatM) * (1f - color.floatK)
- val b = 255f * (1f - color.floatY) * (1f - color.floatK)
-
- return android.graphics.Color.rgb(
- r.toInt(),
- g.toInt(),
- b.toInt()
- )
- }
-
- override fun convertToPureHueColorInt(color: Color): Int {
- TODO("Not yet implemented")
- }
-
- override fun setFromColorInt(color: Color, value: Int) {
- require(color is IntegerCMYKColor) { "Unsupported color type supplied" }
-
- val r = android.graphics.Color.red(value) / 255f
- val g = android.graphics.Color.green(value) / 255f
- val b = android.graphics.Color.blue(value) / 255f
-
- val k = 1f - requireNotNull(
- arrayOf(
- r,
- g,
- b
- ).maxOrNull()
- )
- val c = (1f - r - k) / (1f - k)
- val m = (1f - g - k) / (1f - k)
- val y = (1f - b - k) / (1f - k)
-
- color.copyValuesFrom(
- intArrayOf(
- (c * 100f).toInt(),
- (m * 100f).toInt(),
- (y * 100f).toInt(),
- (k * 100f).toInt()
- )
- )
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerHSLColorConverter.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerHSLColorConverter.kt
deleted file mode 100644
index 3ac7ea4..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerHSLColorConverter.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-package codes.side.andcolorpicker.converter
-
-import androidx.annotation.ColorInt
-import androidx.core.graphics.ColorUtils
-import codes.side.andcolorpicker.model.Color
-import codes.side.andcolorpicker.model.IntegerHSLColor
-
-// Should it be generic?
-class IntegerHSLColorConverter : ColorConverter {
-
- private val convertToColorIntHSLCache = FloatArray(3)
-
- override fun convertToOpaqueColorInt(color: Color): Int {
- require(color is IntegerHSLColor) { "Unsupported color type supplied" }
-
- // TODO: Simplify
- convertToColorIntHSLCache[IntegerHSLColor.Component.H.index] = color.floatH
- convertToColorIntHSLCache[IntegerHSLColor.Component.S.index] = color.floatS
- convertToColorIntHSLCache[IntegerHSLColor.Component.L.index] = color.floatL
-
- return ColorUtils.HSLToColor(convertToColorIntHSLCache)
- }
-
- override fun convertToColorInt(color: Color): Int {
- require(color is IntegerHSLColor) { "Unsupported color type supplied" }
-
- val opaqueColor = convertToOpaqueColorInt(color)
- return ColorUtils.setAlphaComponent(
- opaqueColor,
- color.intA
- )
- }
-
- private val convertToPureColorIntHSLCache = FloatArray(3)
-
- override fun convertToPureHueColorInt(color: Color): Int {
- require(color is IntegerHSLColor) { "Unsupported color type supplied" }
-
- // TODO: Simplify
- convertToPureColorIntHSLCache[IntegerHSLColor.Component.H.index] = color.floatH
- convertToPureColorIntHSLCache[IntegerHSLColor.Component.S.index] =
- IntegerHSLColor.Component.S.normalizedDefaultValue
- convertToPureColorIntHSLCache[IntegerHSLColor.Component.L.index] =
- IntegerHSLColor.Component.L.normalizedDefaultValue
- return ColorUtils.HSLToColor(convertToPureColorIntHSLCache)
- }
-
- override fun setFromColorInt(color: Color, value: Int) {
- require(color is IntegerHSLColor) { "Unsupported color type supplied" }
-
- // TODO: Cache
- val hslOutput = FloatArray(3)
- ColorUtils.colorToHSL(
- value,
- hslOutput
- )
-
- // TODO: Cache
- color.copyValuesFrom(
- intArrayOf(
- hslOutput[IntegerHSLColor.Component.H.index].toInt(),
- (hslOutput[IntegerHSLColor.Component.S.index] * 100f).toInt(),
- (hslOutput[IntegerHSLColor.Component.L.index] * 100f).toInt(),
- android.graphics.Color.alpha(value)
- )
- )
- }
-
- private val hsColorIntHSLCache = FloatArray(3)
-
- @ColorInt
- fun convertToDefaultLightness(color: Color): Int {
- require(color is IntegerHSLColor) { "Unsupported color type supplied" }
-
- hsColorIntHSLCache[IntegerHSLColor.Component.H.index] = color.floatH
- hsColorIntHSLCache[IntegerHSLColor.Component.S.index] = color.floatS
- hsColorIntHSLCache[IntegerHSLColor.Component.L.index] =
- IntegerHSLColor.Component.L.normalizedDefaultValue
- return ColorUtils.HSLToColor(hsColorIntHSLCache)
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerLABColorConverter.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerLABColorConverter.kt
deleted file mode 100644
index 0851abf..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerLABColorConverter.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package codes.side.andcolorpicker.converter
-
-import androidx.core.graphics.ColorUtils
-import codes.side.andcolorpicker.model.Color
-import codes.side.andcolorpicker.model.IntegerLABColor
-
-class IntegerLABColorConverter : ColorConverter {
-
- override fun convertToOpaqueColorInt(color: Color): Int {
- TODO("Not yet implemented")
- }
-
- override fun convertToColorInt(color: Color): Int {
- require(color is IntegerLABColor) { "Unsupported color type supplied" }
-
- return ColorUtils.LABToColor(
- color.intL.toDouble(),
- color.intA.toDouble(),
- color.intB.toDouble()
- )
- }
-
- override fun convertToPureHueColorInt(color: Color): Int {
- TODO("Not yet implemented")
- }
-
- override fun setFromColorInt(color: Color, value: Int) {
- require(color is IntegerLABColor) { "Unsupported color type supplied" }
- TODO("Not yet implemented")
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerRGBColorConverter.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerRGBColorConverter.kt
deleted file mode 100644
index e5346db..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/converter/IntegerRGBColorConverter.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package codes.side.andcolorpicker.converter
-
-import codes.side.andcolorpicker.model.Color
-import codes.side.andcolorpicker.model.IntegerRGBColor
-
-class IntegerRGBColorConverter : ColorConverter {
-
- override fun convertToOpaqueColorInt(color: Color): Int {
- TODO("Not yet implemented")
- }
-
- override fun convertToColorInt(color: Color): Int {
- require(color is IntegerRGBColor) { "Unsupported color type supplied" }
-
- return android.graphics.Color.argb(
- color.intA,
- color.intR,
- color.intG,
- color.intB
- )
- }
-
- override fun convertToPureHueColorInt(color: Color): Int {
- TODO("Not yet implemented")
- }
-
- override fun setFromColorInt(color: Color, value: Int) {
- require(color is IntegerRGBColor) { "Unsupported color type supplied" }
-
- color.copyValuesFrom(
- intArrayOf(
- android.graphics.Color.alpha(value),
- android.graphics.Color.red(value),
- android.graphics.Color.green(value),
- android.graphics.Color.blue(value)
- )
- )
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/dialogs/ColorPickerDialogFragment.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/dialogs/ColorPickerDialogFragment.kt
deleted file mode 100644
index 86082c8..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/dialogs/ColorPickerDialogFragment.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package codes.side.andcolorpicker.dialogs
-
-import android.app.Dialog
-import android.os.Bundle
-import androidx.appcompat.app.AlertDialog
-import androidx.fragment.app.DialogFragment
-import codes.side.andcolorpicker.R
-
-// TODO: Accept initial color value
-// TODO: Accept resources
-// TODO: Propagate picked value
-// TODO: Support out-of-box color model selection
-class ColorPickerDialogFragment : DialogFragment() {
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- return AlertDialog.Builder(requireActivity())
- .setTitle(R.string.title_dialog_pick)
- .setMessage(R.string.title_dialog_pick_message)
- .setView(
- R.layout.layout_dialog_hsla
- )
- .setPositiveButton(
- R.string.action_dialog_pick_positive
- ) { _, _ ->
-
- }
- .setNegativeButton(
- R.string.action_dialog_pick_negative
- ) { _, _ ->
-
- }
- .create()
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/group/PickerGroup.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/group/PickerGroup.kt
deleted file mode 100644
index 2e7bbb9..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/group/PickerGroup.kt
+++ /dev/null
@@ -1,131 +0,0 @@
-package codes.side.andcolorpicker.group
-
-import codes.side.andcolorpicker.model.Color
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-
-open class PickerGroup :
- ColorSeekBar.OnColorPickListener, C>,
- Iterable> {
-
- // Kinda prioritized collection
- private val pickers = linkedSetOf>()
- private val colorPickListeners = hashSetOf, C>>()
-
- fun addListener(listener: ColorSeekBar.OnColorPickListener, C>) {
- colorPickListeners.add(listener)
- }
-
- fun removeListener(listener: ColorSeekBar.OnColorPickListener, C>) {
- colorPickListeners.remove(listener)
- }
-
- fun clearListeners() {
- colorPickListeners.clear()
- }
-
- fun setColor(color: C) {
- pickers.firstOrNull()?.pickedColor = color
- }
-
- fun registerPicker(picker: ColorSeekBar) {
- picker.addListener(this)
- pickers.add(picker)
- // Sync state on register
- notifyGroupOnBroadcastFrom(
- picker,
- picker.pickedColor
- )
- }
-
- fun unregisterPicker(picker: ColorSeekBar) {
- picker.removeListener(this)
- pickers.remove(picker)
- }
-
- override fun onColorPicking(
- picker: ColorSeekBar,
- color: C,
- value: Int,
- fromUser: Boolean
- ) {
- notifyGroupOnBroadcastFrom(
- picker,
- color
- )
- colorPickListeners.forEach {
- it.onColorPicking(
- picker,
- color,
- value,
- fromUser
- )
- }
- }
-
- override fun onColorPicked(
- picker: ColorSeekBar,
- color: C,
- value: Int,
- fromUser: Boolean
- ) {
- notifyGroupOnBroadcastFrom(
- picker,
- color
- )
- colorPickListeners.forEach {
- it.onColorPicked(
- picker,
- color,
- value,
- fromUser
- )
- }
- }
-
- override fun onColorChanged(
- picker: ColorSeekBar,
- color: C,
- value: Int
- ) {
- notifyGroupOnBroadcastFrom(
- picker,
- color
- )
- colorPickListeners.forEach {
- it.onColorChanged(
- picker,
- color,
- value
- )
- }
- }
-
- private fun notifyGroupOnBroadcastFrom(
- picker: ColorSeekBar,
- color: C
- ) {
- disableListeners()
- pickers.filter { it != picker }.forEach {
- it.pickedColor = color
- }
- enableListeners()
- }
-
- private fun disableListeners() {
- setListenerEnabled(false)
- }
-
- private fun enableListeners() {
- setListenerEnabled(true)
- }
-
- private fun setListenerEnabled(isEnabled: Boolean) {
- pickers.forEach {
- it.notifyListeners = isEnabled
- }
- }
-
- override fun iterator(): Iterator> {
- return pickers.iterator()
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/group/PickerGroupExtensions.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/group/PickerGroupExtensions.kt
deleted file mode 100644
index 89be405..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/group/PickerGroupExtensions.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package codes.side.andcolorpicker.group
-
-import codes.side.andcolorpicker.model.Color
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-
-fun PickerGroup.registerPickers(vararg pickers: ColorSeekBar) {
- registerPickers(listOf(*pickers))
-}
-
-fun PickerGroup.registerPickers(pickers: Iterable>) {
- pickers.forEach {
- registerPicker(it)
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/group/PickerGroupLayoutWrapper.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/group/PickerGroupLayoutWrapper.kt
deleted file mode 100644
index 77694a6..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/group/PickerGroupLayoutWrapper.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package codes.side.andcolorpicker.group
-
-import android.view.View
-
-// TODO: Wrap ready-to-use layouts and PickerGroups here
-class PickerGroupLayoutWrapper(private var view: View) {
- init {
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/hsl/HSLColorPickerSeekBar.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/hsl/HSLColorPickerSeekBar.kt
deleted file mode 100644
index 10c6fe9..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/hsl/HSLColorPickerSeekBar.kt
+++ /dev/null
@@ -1,390 +0,0 @@
-package codes.side.andcolorpicker.hsl
-
-import android.content.Context
-import android.graphics.Color
-import android.graphics.drawable.Drawable
-import android.graphics.drawable.GradientDrawable
-import android.graphics.drawable.LayerDrawable
-import android.util.AttributeSet
-import androidx.core.graphics.ColorUtils
-import codes.side.andcolorpicker.R
-import codes.side.andcolorpicker.converter.IntegerHSLColorConverter
-import codes.side.andcolorpicker.model.IntegerHSLColor
-import codes.side.andcolorpicker.model.factory.HSLColorFactory
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-import codes.side.andcolorpicker.view.picker.GradientColorSeekBar
-
-// TODO: Minimize resource reads
-// TODO: Add logger solution
-// TODO: Add call flow diagram
-// TODO: Add checks and reduce calls count
-// TODO: Limit used SDK properties usage
-class HSLColorPickerSeekBar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyle: Int = R.attr.seekBarStyle
-) :
- GradientColorSeekBar(
- HSLColorFactory(),
- context,
- attrs,
- defStyle
- ) {
-
- override val colorConverter: IntegerHSLColorConverter
- get() = super.colorConverter as IntegerHSLColorConverter
-
- private var modeInitialized = false
- private var _mode: Mode? = null
- var mode: Mode
- get() {
- return requireNotNull(_mode) { "Mode is not initialized yet" }
- }
- set(value) {
- modeInitialized = true
- if (_mode == value) {
- return
- }
- _mode = value
- refreshProperties()
- refreshProgressFromCurrentColor()
- refreshProgressDrawable()
- refreshThumb()
- }
-
- private var coloringModeInitialized = false
- private var _coloringMode: ColoringMode? = null
- var coloringMode: ColoringMode
- get() {
- return requireNotNull(_coloringMode) { "Coloring mode is not initialized yet" }
- }
- set(value) {
- coloringModeInitialized = true
- if (_coloringMode == value) {
- return
- }
- _coloringMode = value
- refreshProgressDrawable()
- refreshThumb()
- }
-
- private val paintDrawableStrokeSaturationHSLCache by lazy { IntegerHSLColor() }
- private val paintDrawableStrokeLightnessHSLCache by lazy { IntegerHSLColor() }
-
- private val progressDrawableSaturationColorsCache by lazy { IntArray(2) }
- private val progressDrawableLightnessColorsCache by lazy { IntArray(3) }
-
- private val zeroSaturationOutputColorHSLCache by lazy { ZERO_SATURATION_COLOR_HSL.clone() }
-
- private val createHueOutputColorCheckpointsHSLCache by lazy {
- FloatArray(3)
- }
-
- init {
- init(attrs)
- }
-
- private fun init(attrs: AttributeSet? = null) {
- val typedArray = context.theme.obtainStyledAttributes(
- attrs,
- R.styleable.HSLColorPickerSeekBar,
- 0,
- 0
- )
-
- mode = Mode.values()[typedArray.getInteger(
- R.styleable.HSLColorPickerSeekBar_hslMode,
- DEFAULT_MODE.ordinal
- )]
- coloringMode = ColoringMode.values()[typedArray.getInteger(
- R.styleable.HSLColorPickerSeekBar_hslColoringMode,
- DEFAULT_COLORING_MODE.ordinal
- )]
-
- typedArray.recycle()
- }
-
- override fun setMax(max: Int) {
- if (modeInitialized && max != mode.absoluteProgress) {
- throw IllegalArgumentException("Current mode supports ${mode.absoluteProgress} max value only, was $max")
- }
- super.setMax(max)
- }
-
- override fun onUpdateColorFrom(color: IntegerHSLColor, value: IntegerHSLColor) {
- color.setFrom(value)
- }
-
- override fun onRefreshProperties() {
- if (!modeInitialized) {
- return
- }
- max = mode.absoluteProgress
- }
-
- override fun onRefreshProgressFromColor(color: IntegerHSLColor): Int? {
- if (!modeInitialized) {
- return null
- }
-
- return -mode.minProgress + when (mode) {
- Mode.MODE_HUE -> {
- internalPickedColor.intH
- }
- Mode.MODE_SATURATION -> {
- internalPickedColor.intS
- }
- Mode.MODE_LIGHTNESS -> {
- internalPickedColor.intL
- }
- }
- }
-
- // TODO: Get rid of toIntArray allocations
- private fun createHueOutputColorCheckpoints(): IntArray {
- return HUE_COLOR_CHECKPOINTS
- .map {
- ColorUtils.colorToHSL(
- it,
- createHueOutputColorCheckpointsHSLCache
- )
- createHueOutputColorCheckpointsHSLCache[IntegerHSLColor.Component.S.index] =
- internalPickedColor.floatS
- createHueOutputColorCheckpointsHSLCache[IntegerHSLColor.Component.L.index] =
- internalPickedColor.floatL
- ColorUtils.HSLToColor(createHueOutputColorCheckpointsHSLCache)
- }.toIntArray()
- }
-
- private fun refreshZeroSaturationOutputColorHSLCache() {
- zeroSaturationOutputColorHSLCache[2] = internalPickedColor.floatL
- }
-
- override fun onRefreshProgressDrawable(progressDrawable: LayerDrawable) {
- if (!coloringModeInitialized || !modeInitialized) {
- return
- }
-
- (progressDrawable.getDrawable(0) as GradientDrawable).colors = when (mode) {
- Mode.MODE_HUE -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> HUE_COLOR_CHECKPOINTS
- ColoringMode.OUTPUT_COLOR -> createHueOutputColorCheckpoints()
- }
- }
- Mode.MODE_SATURATION -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> {
- progressDrawableSaturationColorsCache.also {
- it[0] =
- ZERO_SATURATION_COLOR_INT
- it[1] = colorConverter.convertToPureHueColorInt(internalPickedColor)
- }
- }
- ColoringMode.OUTPUT_COLOR -> {
- refreshZeroSaturationOutputColorHSLCache()
-
- progressDrawableSaturationColorsCache.also {
- it[0] =
- ColorUtils.HSLToColor(zeroSaturationOutputColorHSLCache)
- it[1] = colorConverter.convertToColorInt(internalPickedColor)
- }
- }
- }
- }
- Mode.MODE_LIGHTNESS -> {
- progressDrawableLightnessColorsCache.also {
- it[0] = Color.BLACK
- it[1] = when (coloringMode) {
- ColoringMode.PURE_COLOR -> colorConverter.convertToPureHueColorInt(internalPickedColor)
- ColoringMode.OUTPUT_COLOR -> colorConverter.convertToDefaultLightness(internalPickedColor)
- }
- it[2] = Color.WHITE
- }
- }
- }
- }
-
- override fun onRefreshThumb(thumbColoringDrawables: Set) {
- thumbColoringDrawables.forEach {
- when (it) {
- is GradientDrawable -> {
- paintThumbStroke(it)
- }
- is LayerDrawable -> {
- paintThumbStroke(it.getDrawable(0) as GradientDrawable)
- }
- }
- }
- }
-
- override fun onRefreshColorFromProgress(color: IntegerHSLColor, progress: Int): Boolean {
- if (!modeInitialized) {
- return false
- }
-
- val unmaskedProgress = mode.minProgress + progress
- return when (mode) {
- Mode.MODE_HUE -> {
- val currentH = color.intH
- if (currentH != unmaskedProgress) {
- color.intH = unmaskedProgress
- true
- } else {
- false
- }
- }
- Mode.MODE_SATURATION -> {
- val currentS = color.intS
- if (currentS != unmaskedProgress) {
- color.intS = unmaskedProgress
- true
- } else {
- false
- }
- }
- Mode.MODE_LIGHTNESS -> {
- val currentL = color.intL
- if (currentL != unmaskedProgress) {
- color.intL = unmaskedProgress
- true
- } else {
- false
- }
- }
- }
- }
-
- // TODO: Deal with int arrays
- private fun paintThumbStroke(drawable: GradientDrawable) {
- if (!coloringModeInitialized || !modeInitialized) {
- return
- }
-
- drawable.setStroke(
- thumbStrokeWidthPx,
- when (mode) {
- Mode.MODE_HUE -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> {
- colorConverter.convertToPureHueColorInt(internalPickedColor)
- }
- ColoringMode.OUTPUT_COLOR -> {
- colorConverter.convertToColorInt(internalPickedColor)
- }
- }
- }
- Mode.MODE_SATURATION -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> {
- colorConverter.convertToColorInt(
- paintDrawableStrokeSaturationHSLCache.also {
- it.copyValuesFrom(
- intArrayOf(
- internalPickedColor.intH,
- internalPickedColor.intS,
- IntegerHSLColor.Component.L.defaultValue
- )
- )
- }
- )
- }
- ColoringMode.OUTPUT_COLOR -> {
- colorConverter.convertToColorInt(internalPickedColor)
- }
- }
- }
- Mode.MODE_LIGHTNESS -> {
- when (coloringMode) {
- ColoringMode.PURE_COLOR -> {
- colorConverter.convertToColorInt(
- paintDrawableStrokeLightnessHSLCache.also {
- it.copyValuesFrom(
- intArrayOf(
- internalPickedColor.intH,
- IntegerHSLColor.Component.S.defaultValue,
- internalPickedColor.intL.coerceAtMost(COERCE_AT_MOST_LIGHTNING)
- )
- )
- }
- )
- }
- ColoringMode.OUTPUT_COLOR -> {
- colorConverter.convertToColorInt(
- paintDrawableStrokeLightnessHSLCache.also {
- it.copyValuesFrom(
- intArrayOf(
- internalPickedColor.intH,
- internalPickedColor.intS,
- internalPickedColor.intL.coerceAtMost(COERCE_AT_MOST_LIGHTNING)
- )
- )
- }
- )
- }
- }
- }
- }
- )
- }
-
- override fun toString(): String {
- return "HSLColorPickerSeekBar(tag = $tag, _mode=${if (modeInitialized) mode else null}, _currentColor=$internalPickedColor)"
- }
-
- enum class ColoringMode {
- PURE_COLOR,
- OUTPUT_COLOR
- }
-
- enum class Mode(
- override val minProgress: Int,
- override val maxProgress: Int
- ) : ColorSeekBar.Mode {
- // H from HSV/HSL/HSI/HSB
- MODE_HUE(
- IntegerHSLColor.Component.H.minValue,
- IntegerHSLColor.Component.H.maxValue
- ),
-
- // S from HSV/HSL/HSI/HSB
- MODE_SATURATION(
- IntegerHSLColor.Component.S.minValue,
- IntegerHSLColor.Component.S.maxValue
- ),
-
- // INTENSITY, L/I from HSL/HSI
- MODE_LIGHTNESS(
- IntegerHSLColor.Component.L.minValue,
- IntegerHSLColor.Component.L.maxValue
- )
- }
-
- companion object {
- private val DEFAULT_MODE = Mode.MODE_HUE
- private val DEFAULT_COLORING_MODE = ColoringMode.PURE_COLOR
-
- // TODO: Make configurable
- private const val COERCE_AT_MOST_LIGHTNING = 90
- private val HUE_COLOR_CHECKPOINTS = intArrayOf(
- Color.RED,
- Color.YELLOW,
- Color.GREEN,
- Color.CYAN,
- Color.BLUE,
- Color.MAGENTA,
- Color.RED
- )
- private val ZERO_SATURATION_COLOR_INT = Color.rgb(
- 128,
- 128,
- 128
- )
- private val ZERO_SATURATION_COLOR_HSL = FloatArray(3).also {
- ColorUtils.colorToHSL(
- ZERO_SATURATION_COLOR_INT,
- it
- )
- }
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/lab/LABColorPickerSeekBar.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/lab/LABColorPickerSeekBar.kt
deleted file mode 100644
index 1c03c95..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/lab/LABColorPickerSeekBar.kt
+++ /dev/null
@@ -1,285 +0,0 @@
-package codes.side.andcolorpicker.lab
-
-import android.content.Context
-import android.graphics.drawable.Drawable
-import android.graphics.drawable.GradientDrawable
-import android.graphics.drawable.LayerDrawable
-import android.util.AttributeSet
-import androidx.core.graphics.ColorUtils
-import codes.side.andcolorpicker.R
-import codes.side.andcolorpicker.converter.IntegerLABColorConverter
-import codes.side.andcolorpicker.model.IntegerLABColor
-import codes.side.andcolorpicker.model.factory.LABColorFactory
-import codes.side.andcolorpicker.util.mapToIntArray
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-import codes.side.andcolorpicker.view.picker.GradientColorSeekBar
-
-// TODO: Minimize resource reads
-// TODO: Add logger solution
-// TODO: Add call flow diagram
-// TODO: Add checks and reduce calls count
-// TODO: Limit used SDK properties usage
-class LABColorPickerSeekBar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyle: Int = R.attr.seekBarStyle
-) :
- GradientColorSeekBar(
- LABColorFactory(),
- context,
- attrs,
- defStyle
- ) {
- companion object {
- private const val TAG = "LABColorPickerSeekBar"
-
- private val DEFAULT_MODE = Mode.MODE_L
- private val DEFAULT_COLORING_MODE = ColoringMode.OUTPUT_COLOR
-
- private const val PROGRESS_SAMPLING_PERIOD = 10
- }
-
- override val colorConverter: IntegerLABColorConverter
- get() = super.colorConverter as IntegerLABColorConverter
-
- private var modeInitialized = false
- private var _mode: Mode? = null
- var mode: Mode
- get() {
- return requireNotNull(_mode) { "Mode is not initialized yet" }
- }
- set(value) {
- modeInitialized = true
- if (_mode == value) {
- return
- }
- _mode = value
- refreshProperties()
- refreshProgressFromCurrentColor()
- refreshProgressDrawable()
- refreshThumb()
- }
-
- private var coloringModeInitialized = false
- private var _coloringMode: ColoringMode? = null
- var coloringMode: ColoringMode
- get() {
- return requireNotNull(_coloringMode) { "Coloring mode is not initialized yet" }
- }
- set(value) {
- coloringModeInitialized = true
- if (_coloringMode == value) {
- return
- }
- _coloringMode = value
- refreshProgressDrawable()
- refreshThumb()
- }
-
- init {
- init(attrs)
- }
-
- private fun init(attrs: AttributeSet? = null) {
- val typedArray = context.theme.obtainStyledAttributes(
- attrs,
- R.styleable.LABColorPickerSeekBar,
- 0,
- 0
- )
-
- mode = Mode.values()[typedArray.getInteger(
- R.styleable.LABColorPickerSeekBar_labMode,
- DEFAULT_MODE.ordinal
- )]
- coloringMode = ColoringMode.values()[typedArray.getInteger(
- R.styleable.LABColorPickerSeekBar_labColoringMode,
- DEFAULT_COLORING_MODE.ordinal
- )]
-
- typedArray.recycle()
- }
-
- override fun setMax(max: Int) {
- if (modeInitialized && max != mode.absoluteProgress) {
- throw IllegalArgumentException("Current mode supports ${mode.absoluteProgress} max value only, was $max")
- }
- super.setMax(max)
- }
-
- override fun onUpdateColorFrom(color: IntegerLABColor, value: IntegerLABColor) {
- color.setFrom(value)
- }
-
- override fun onRefreshProperties() {
- if (!modeInitialized) {
- return
- }
- max = mode.absoluteProgress
- }
-
- override fun onRefreshProgressFromColor(color: IntegerLABColor): Int? {
- if (!modeInitialized) {
- return null
- }
-
- return -mode.minProgress + when (mode) {
- Mode.MODE_L -> {
- internalPickedColor.intL
- }
- Mode.MODE_A -> {
- internalPickedColor.intA
- }
- Mode.MODE_B -> {
- internalPickedColor.intB
- }
- }
- }
-
- override fun onRefreshProgressDrawable(progressDrawable: LayerDrawable) {
- if (!coloringModeInitialized || !modeInitialized) {
- return
- }
-
- val sampledRangeIntArray = mode.sampledRangeIntArray
- val outputIntArray = IntArray(sampledRangeIntArray.size)
-
- (progressDrawable.getDrawable(0) as GradientDrawable).colors = when (mode) {
- Mode.MODE_L -> {
- when (coloringMode) {
- ColoringMode.OUTPUT_COLOR -> {
- sampledRangeIntArray.mapToIntArray(outputIntArray) {
- ColorUtils.LABToColor(
- it.toDouble(),
- internalPickedColor.intA.toDouble(),
- internalPickedColor.intB.toDouble()
- )
- }
- }
- }
- }
- Mode.MODE_A -> {
- when (coloringMode) {
- ColoringMode.OUTPUT_COLOR -> {
- sampledRangeIntArray.mapToIntArray(outputIntArray) {
- ColorUtils.LABToColor(
- internalPickedColor.intL.toDouble(),
- it.toDouble(),
- internalPickedColor.intB.toDouble()
- )
- }
- }
- }
- }
- Mode.MODE_B -> {
- when (coloringMode) {
- ColoringMode.OUTPUT_COLOR -> {
- sampledRangeIntArray.mapToIntArray(outputIntArray) {
- ColorUtils.LABToColor(
- internalPickedColor.intL.toDouble(),
- internalPickedColor.intA.toDouble(),
- it.toDouble()
- )
- }
- }
- }
- }
- }
- }
-
- override fun onRefreshThumb(thumbColoringDrawables: Set) {
- thumbColoringDrawables.forEach {
- when (it) {
- is GradientDrawable -> {
- paintThumbStroke(it)
- }
- is LayerDrawable -> {
- paintThumbStroke(it.getDrawable(0) as GradientDrawable)
- }
- }
- }
- }
-
- override fun onRefreshColorFromProgress(color: IntegerLABColor, progress: Int): Boolean {
- if (!modeInitialized) {
- return false
- }
-
- val unmaskedProgress = mode.minProgress + progress
- return when (mode) {
- Mode.MODE_L -> {
- val currentValue = color.intL
- if (currentValue != unmaskedProgress) {
- color.intL = unmaskedProgress
- true
- } else {
- false
- }
- }
- Mode.MODE_A -> {
- val currentValue = color.intA
- if (currentValue != unmaskedProgress) {
- color.intA = unmaskedProgress
- true
- } else {
- false
- }
- }
- Mode.MODE_B -> {
- val currentValue = color.intB
- if (currentValue != unmaskedProgress) {
- color.intB = unmaskedProgress
- true
- } else {
- false
- }
- }
- }
- }
-
- // TODO: Deal with int arrays
- private fun paintThumbStroke(drawable: GradientDrawable) {
- if (!coloringModeInitialized || !modeInitialized) {
- return
- }
-
- drawable.setStroke(
- thumbStrokeWidthPx,
- when (coloringMode) {
- ColoringMode.OUTPUT_COLOR -> {
- colorConverter.convertToColorInt(internalPickedColor)
- }
- }
- )
- }
-
- override fun toString(): String {
- return "LABColorPickerSeekBar(tag = $tag, _mode=${if (modeInitialized) mode else null}, _currentColor=$internalPickedColor)"
- }
-
- enum class ColoringMode {
- OUTPUT_COLOR
- }
-
- enum class Mode(
- override val minProgress: Int,
- override val maxProgress: Int
- ) : ColorSeekBar.Mode {
- MODE_L(
- IntegerLABColor.Component.L.minValue,
- IntegerLABColor.Component.L.maxValue
- ),
- MODE_A(
- IntegerLABColor.Component.A.minValue,
- IntegerLABColor.Component.A.maxValue
- ),
- MODE_B(
- IntegerLABColor.Component.B.minValue,
- IntegerLABColor.Component.B.maxValue
- );
-
- private val range by lazy { minProgress..maxProgress }
- private val sampledRange by lazy { range.step(PROGRESS_SAMPLING_PERIOD) }
- val sampledRangeIntArray by lazy { sampledRange.map { it }.toIntArray() }
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/Color.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/Color.kt
deleted file mode 100644
index 42dd28b..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/Color.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package codes.side.andcolorpicker.model
-
-// TODO: Make serializable / parcelable?
-// TODO: Leave as a marker?
-interface Color : Cloneable {
-
- val colorKey: ColorKey
-
- // [0, 1]
- val alpha: Float
-
- public override fun clone(): Any {
- return super.clone()
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/ColorKey.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/ColorKey.kt
deleted file mode 100644
index 51d33ff..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/ColorKey.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package codes.side.andcolorpicker.model
-
-enum class ColorKey {
- HSL,
- RGB,
- CMYK,
- LAB
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerCMYKColor.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerCMYKColor.kt
deleted file mode 100644
index e3956a6..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerCMYKColor.kt
+++ /dev/null
@@ -1,184 +0,0 @@
-package codes.side.andcolorpicker.model
-
-// TODO: Remove float properties? Move to converters?
-class IntegerCMYKColor : IntegerColor(
- COMPONENTS_COUNT,
- DEFAULT_VALUES
-) {
-
- override val colorKey = ColorKey.CMYK
-
- override val alpha: Float
- get() {
- return intA / Component.A.maxValue.toFloat()
- }
-
- var intA: Int
- get() {
- return intValues[Component.A.index]
- }
- set(value) {
- setValue(
- Component.A.index,
- value,
- Component.A.minValue,
- Component.A.maxValue
- )
- }
-
- var floatC: Float
- get() {
- return intC / Component.C.maxValue.toFloat()
- }
- set(value) {
- intC = (value * Component.C.maxValue).toInt()
- }
- var intC: Int
- get() {
- return intValues[Component.C.index]
- }
- set(value) {
- setValue(
- Component.C.index,
- value,
- Component.C.minValue,
- Component.C.maxValue
- )
- }
-
- var floatM: Float
- get() {
- return intM / Component.M.maxValue.toFloat()
- }
- set(value) {
- intM = (value * Component.M.maxValue).toInt()
- }
- var intM: Int
- get() {
- return intValues[Component.M.index]
- }
- set(value) {
- setValue(
- Component.M.index,
- value,
- Component.M.minValue,
- Component.M.maxValue
- )
- }
-
- var floatY: Float
- get() {
- return intY / Component.Y.maxValue.toFloat()
- }
- set(value) {
- intY = (value * Component.Y.maxValue).toInt()
- }
- var intY: Int
- get() {
- return intValues[Component.Y.index]
- }
- set(value) {
- setValue(
- Component.Y.index,
- value,
- Component.Y.minValue,
- Component.Y.maxValue
- )
- }
-
- var floatK: Float
- get() {
- return intK / Component.K.maxValue.toFloat()
- }
- set(value) {
- intK = (value * Component.K.maxValue).toInt()
- }
- var intK: Int
- get() {
- return intValues[Component.K.index]
- }
- set(value) {
- setValue(
- Component.K.index,
- value,
- Component.K.minValue,
- Component.K.maxValue
- )
- }
-
- override fun clone(): IntegerCMYKColor {
- return super.clone() as IntegerCMYKColor
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- if (!super.equals(other)) return false
-
- other as IntegerCMYKColor
-
- if (colorKey != other.colorKey) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = super.hashCode()
- result = 31 * result + colorKey.hashCode()
- return result
- }
-
- // TODO: Make Component top-level?
- // TODO: Make tree?
- // TODO: Use range?
- enum class Component(
- val defaultValue: Int,
- val minValue: Int,
- val maxValue: Int
- ) {
- C(
- 0,
- 0,
- 100
- ),
- M(
- 0,
- 0,
- 100
- ),
- Y(
- 0,
- 0,
- 100
- ),
- K(
- 0,
- 0,
- 100
- ),
- A(
- 255,
- 0,
- 255
- );
-
- // TODO: Review approach
- val index: Int
- get() {
- return ordinal
- }
-
- // TODO: Adapt for non-zero min values
- val normalizedDefaultValue: Float
- get() {
- return defaultValue / maxValue.toFloat()
- }
- }
-
- companion object {
- private val COMPONENTS_COUNT = Component.values().size
-
- private val DEFAULT_VALUES = Component
- .values().map { it.defaultValue }.toIntArray()
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerColor.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerColor.kt
deleted file mode 100644
index e1557a2..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerColor.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package codes.side.andcolorpicker.model
-
-import android.util.Log
-
-abstract class IntegerColor(
- componentsCount: Int,
- defaultValues: IntArray? = null
-) : Color {
-
- protected val intValues = IntArray(componentsCount)
-
- init {
- defaultValues?.copyInto(intValues)
- }
-
- protected fun setValue(index: Int, value: Int, minValue: Int, maxValue: Int) {
- intValues[index] = value.coerceIn(
- minValue,
- maxValue
- )
- Log.d(
- TAG,
- "Set $this from ${intValues.contentToString()}"
- )
- }
-
- fun copyValuesTo(array: IntArray) {
- intValues.copyInto(array)
- }
-
- fun copyValuesFrom(array: IntArray) {
- if (array.size != intValues.size) {
- Log.d(
- TAG,
- "Copying values from array with different size"
- )
- }
- array.copyInto(intValues)
- }
-
- fun setFrom(color: IntegerColor) {
- color.copyValuesTo(intValues)
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
-
- other as IntegerColor
-
- if (!intValues.contentEquals(other.intValues)) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- return intValues.contentHashCode()
- }
-
- companion object {
- private const val TAG = "IntegerColor"
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerHSLColor.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerHSLColor.kt
deleted file mode 100644
index a67e924..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerHSLColor.kt
+++ /dev/null
@@ -1,184 +0,0 @@
-package codes.side.andcolorpicker.model
-
-import kotlin.random.Random
-
-// TODO: Provide precision options
-// TODO: Remove float properties? Move to converters?
-class IntegerHSLColor : IntegerColor(
- COMPONENTS_COUNT,
- DEFAULT_VALUES
-) {
-
- override val colorKey = ColorKey.HSL
-
- override val alpha: Float
- get() {
- return intA / Component.A.maxValue.toFloat()
- }
-
- var intA: Int
- get() {
- return intValues[Component.A.index]
- }
- set(value) {
- setValue(
- Component.A.index,
- value,
- Component.A.minValue,
- Component.A.maxValue
- )
- }
-
- var floatH: Float
- get() {
- return intH.toFloat()
- }
- set(value) {
- intH = value.toInt()
- }
- var intH: Int
- get() {
- return intValues[Component.H.index]
- }
- set(value) {
- setValue(
- Component.H.index,
- value,
- Component.H.minValue,
- Component.H.maxValue
- )
- }
- var floatS: Float
- get() {
- return intS / Component.S.maxValue.toFloat()
- }
- set(value) {
- intS = (value * Component.S.maxValue).toInt()
- }
- var intS: Int
- get() {
- return intValues[Component.S.index]
- }
- set(value) {
- setValue(
- Component.S.index,
- value,
- Component.S.minValue,
- Component.S.maxValue
- )
- }
- var floatL: Float
- get() {
- return intL / Component.L.maxValue.toFloat()
- }
- set(value) {
- intL = (value * Component.L.maxValue).toInt()
- }
- var intL: Int
- get() {
- return intValues[Component.L.index]
- }
- set(value) {
- setValue(
- Component.L.index,
- value,
- Component.L.minValue,
- Component.L.maxValue
- )
- }
-
- override fun clone(): IntegerHSLColor {
- return IntegerHSLColor().also {
- it.setFrom(this)
- }
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- if (!super.equals(other)) return false
-
- other as IntegerHSLColor
-
- if (colorKey != other.colorKey) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = super.hashCode()
- result = 31 * result + colorKey.hashCode()
- return result
- }
-
- // TODO: Make Component top-level?
- // TODO: Make tree?
- // TODO: Use range?
- enum class Component(
- val defaultValue: Int,
- val minValue: Int,
- val maxValue: Int
- ) {
- H(
- 0,
- 0,
- 360
- ),
- S(
- 100,
- 0,
- 100
- ),
- L(
- 50,
- 0,
- 100
- ),
- A(
- 255,
- 0,
- 255
- );
-
- // TODO: Review approach
- val index: Int
- get() {
- return ordinal
- }
-
- // TODO: Adapt for non-zero min values
- val normalizedDefaultValue: Float
- get() {
- return defaultValue / maxValue.toFloat()
- }
- }
-
- companion object {
- private const val TAG = "IntegerHSLColor"
- private val COMPONENTS_COUNT = Component.values().size
-
- private val DEFAULT_VALUES = Component
- .values().map { it.defaultValue }.toIntArray()
-
- fun createRandomColor(pure: Boolean = false): IntegerHSLColor {
- return IntegerHSLColor().also {
- it.copyValuesFrom(
- intArrayOf(
- Random.Default.nextInt(
- Component.H.minValue,
- Component.H.maxValue
- ),
- if (pure) Component.S.defaultValue else Random.Default.nextInt(
- Component.S.minValue,
- Component.S.maxValue
- ),
- if (pure) Component.L.defaultValue else Random.Default.nextInt(
- Component.L.minValue,
- Component.L.maxValue
- )
- )
- )
- }
- }
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerLABColor.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerLABColor.kt
deleted file mode 100644
index f1eb04a..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerLABColor.kt
+++ /dev/null
@@ -1,165 +0,0 @@
-package codes.side.andcolorpicker.model
-
-// TODO: Provide precision options
-// TODO: Remove float properties? Move to converters?
-class IntegerLABColor : IntegerColor(
- COMPONENTS_COUNT,
- DEFAULT_VALUES
-) {
-
- override val colorKey = ColorKey.LAB
-
- override val alpha: Float
- get() {
- return intAlpha / Component.ALPHA.maxValue.toFloat()
- }
-
- var intAlpha: Int
- get() {
- return intValues[Component.ALPHA.index]
- }
- set(value) {
- setValue(
- Component.ALPHA.index,
- value,
- Component.ALPHA.minValue,
- Component.ALPHA.maxValue
- )
- }
-
- var intL: Int
- get() {
- return intValues[Component.L.index]
- }
- set(value) {
- setValue(
- Component.L.index,
- value,
- Component.L.minValue,
- Component.L.maxValue
- )
- }
-
- var intA: Int
- get() {
- return intValues[Component.A.index]
- }
- set(value) {
- setValue(
- Component.A.index,
- value,
- Component.A.minValue,
- Component.A.maxValue
- )
- }
-
- var intB: Int
- get() {
- return intValues[Component.B.index]
- }
- set(value) {
- setValue(
- Component.B.index,
- value,
- Component.B.minValue,
- Component.B.maxValue
- )
- }
-
- override fun clone(): IntegerLABColor {
- return IntegerLABColor().also {
- it.setFrom(this)
- }
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- if (!super.equals(other)) return false
-
- other as IntegerLABColor
-
- if (colorKey != other.colorKey) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = super.hashCode()
- result = 31 * result + colorKey.hashCode()
- return result
- }
-
- // TODO: Make Component top-level?
- // TODO: Make tree?
- // TODO: Use range?
- enum class Component(
- val defaultValue: Int,
- val minValue: Int,
- val maxValue: Int
- ) {
- L(
- 50,
- 0,
- 100
- ),
- A(
- 0,
- -128,
- 127
- ),
- B(
- 0,
- -128,
- 127
- ),
- ALPHA(
- 255,
- 0,
- 255
- );
-
- // TODO: Review approach
- val index: Int
- get() {
- return ordinal
- }
-
- // TODO: Adapt for non-zero min values
- val normalizedDefaultValue: Float
- get() {
- return defaultValue / maxValue.toFloat()
- }
- }
-
- companion object {
- private val COMPONENTS_COUNT = Component.values().size
-
- private val DEFAULT_VALUES = Component
- .values().map { it.defaultValue }.toIntArray()
- }
-}
-
-var IntegerLABColor.floatL: Float
- get() {
- return intL.toFloat()
- }
- set(value) {
- intL = value.toInt()
- }
-
-var IntegerLABColor.floatA: Float
- get() {
- return intA / IntegerLABColor.Component.A.maxValue.toFloat()
- }
- set(value) {
- intA = (value * IntegerLABColor.Component.A.maxValue).toInt()
- }
-
-var IntegerLABColor.floatB: Float
- get() {
- return intB / IntegerLABColor.Component.B.maxValue.toFloat()
- }
- set(value) {
- intB = (value * IntegerLABColor.Component.B.maxValue).toInt()
- }
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerRGBColor.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerRGBColor.kt
deleted file mode 100644
index a1fa1c4..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/IntegerRGBColor.kt
+++ /dev/null
@@ -1,158 +0,0 @@
-package codes.side.andcolorpicker.model
-
-class IntegerRGBColor : IntegerColor(
- COMPONENTS_COUNT,
- DEFAULT_RGB_VALUES
-) {
-
- override val colorKey = ColorKey.RGB
-
- override val alpha: Float
- get() {
- return intA / Component.A.maxValue.toFloat()
- }
- var intA: Int
- get() {
- return intValues[Component.A.index]
- }
- set(value) {
- setValue(
- Component.A.index,
- value,
- Component.A.minValue,
- Component.A.maxValue
- )
- }
-
- var floatR: Float
- get() {
- return intR.toFloat()
- }
- set(value) {
- intR = value.toInt()
- }
- var intR: Int
- get() {
- return intValues[Component.R.index]
- }
- set(value) {
- setValue(
- Component.R.index,
- value,
- Component.R.minValue,
- Component.R.maxValue
- )
- }
-
- var floatG: Float
- get() {
- return intG.toFloat()
- }
- set(value) {
- intG = value.toInt()
- }
- var intG: Int
- get() {
- return intValues[Component.G.index]
- }
- set(value) {
- setValue(
- Component.G.index,
- value,
- Component.G.minValue,
- Component.G.maxValue
- )
- }
-
- var floatB: Float
- get() {
- return intB.toFloat()
- }
- set(value) {
- intB = value.toInt()
- }
- var intB: Int
- get() {
- return intValues[Component.B.index]
- }
- set(value) {
- setValue(
- Component.B.index,
- value,
- Component.B.minValue,
- Component.B.maxValue
- )
- }
-
- override fun clone(): IntegerRGBColor {
- return super.clone() as IntegerRGBColor
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- if (!super.equals(other)) return false
-
- other as IntegerRGBColor
-
- if (colorKey != other.colorKey) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = super.hashCode()
- result = 31 * result + colorKey.hashCode()
- return result
- }
-
- // TODO: Make Component top-level?
- // TODO: Make tree?
- // TODO: Use range?
- enum class Component(
- val defaultValue: Int,
- val minValue: Int,
- val maxValue: Int
- ) {
- R(
- 0,
- 0,
- 255
- ),
- G(
- 0,
- 0,
- 255
- ),
- B(
- 0,
- 0,
- 255
- ),
- A(
- 255,
- 0,
- 255
- );
-
- // TODO: Review approach
- val index: Int
- get() {
- return ordinal
- }
-
- // TODO: Adapt for non-zero min values
- val normalizedDefaultValue: Float
- get() {
- return defaultValue / maxValue.toFloat()
- }
- }
-
- companion object {
- private const val TAG = "IntegerRGBColor"
- private val COMPONENTS_COUNT = Component.values().size
-
- private val DEFAULT_RGB_VALUES = Component
- .values().map { it.defaultValue }.toIntArray()
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/CMYKColorFactory.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/CMYKColorFactory.kt
deleted file mode 100644
index b0ead4d..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/CMYKColorFactory.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package codes.side.andcolorpicker.model.factory
-
-import codes.side.andcolorpicker.model.IntegerCMYKColor
-
-class CMYKColorFactory : ColorFactory() {
-
- override fun create(): IntegerCMYKColor {
- return IntegerCMYKColor()
- }
-
- override fun createColorFrom(color: IntegerCMYKColor): IntegerCMYKColor {
- return color.clone()
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/ColorFactory.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/ColorFactory.kt
deleted file mode 100644
index a715c79..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/ColorFactory.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package codes.side.andcolorpicker.model.factory
-
-import codes.side.andcolorpicker.model.Color
-
-abstract class ColorFactory {
-
- abstract fun create(): C
-
- abstract fun createColorFrom(color: C): C
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/HSLColorFactory.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/HSLColorFactory.kt
deleted file mode 100644
index c92e9e3..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/HSLColorFactory.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package codes.side.andcolorpicker.model.factory
-
-import codes.side.andcolorpicker.model.IntegerHSLColor
-
-class HSLColorFactory : ColorFactory() {
-
- override fun create(): IntegerHSLColor {
- return IntegerHSLColor()
- }
-
- override fun createColorFrom(color: IntegerHSLColor): IntegerHSLColor {
- return color.clone()
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/LABColorFactory.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/LABColorFactory.kt
deleted file mode 100644
index 48ba7d0..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/LABColorFactory.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package codes.side.andcolorpicker.model.factory
-
-import codes.side.andcolorpicker.model.IntegerLABColor
-
-class LABColorFactory : ColorFactory() {
-
- override fun create(): IntegerLABColor {
- return IntegerLABColor()
- }
-
- override fun createColorFrom(color: IntegerLABColor): IntegerLABColor {
- return color.clone()
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/RGBColorFactory.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/RGBColorFactory.kt
deleted file mode 100644
index 18a88cb..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/model/factory/RGBColorFactory.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package codes.side.andcolorpicker.model.factory
-
-import codes.side.andcolorpicker.model.IntegerRGBColor
-
-class RGBColorFactory : ColorFactory() {
-
- override fun create(): IntegerRGBColor {
- return IntegerRGBColor()
- }
-
- override fun createColorFrom(color: IntegerRGBColor): IntegerRGBColor {
- return color.clone()
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/rgb/RGBColorPickerSeekBar.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/rgb/RGBColorPickerSeekBar.kt
deleted file mode 100644
index 0e3e010..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/rgb/RGBColorPickerSeekBar.kt
+++ /dev/null
@@ -1,281 +0,0 @@
-package codes.side.andcolorpicker.rgb
-
-import android.content.Context
-import android.graphics.Color
-import android.graphics.drawable.Drawable
-import android.graphics.drawable.GradientDrawable
-import android.graphics.drawable.LayerDrawable
-import android.util.AttributeSet
-import androidx.annotation.StringRes
-import androidx.core.graphics.ColorUtils
-import codes.side.andcolorpicker.R
-import codes.side.andcolorpicker.converter.IntegerRGBColorConverter
-import codes.side.andcolorpicker.model.IntegerRGBColor
-import codes.side.andcolorpicker.model.factory.RGBColorFactory
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-import codes.side.andcolorpicker.view.picker.GradientColorSeekBar
-
-class RGBColorPickerSeekBar @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyle: Int = R.attr.seekBarStyle
-) :
- GradientColorSeekBar(
- RGBColorFactory(),
- context,
- attrs,
- defStyle
- ) {
-
- override val colorConverter: IntegerRGBColorConverter
- get() = super.colorConverter as IntegerRGBColorConverter
-
- private var modeInitialized = false
- private var _mode: Mode? = null
- var mode: Mode
- get() {
- return requireNotNull(_mode) { "Mode is not initialized yet" }
- }
- set(value) {
- modeInitialized = true
- if (_mode == value) {
- return
- }
- _mode = value
- refreshProperties()
- refreshProgressFromCurrentColor()
- refreshProgressDrawable()
- refreshThumb()
- }
-
- private var coloringModeInitialized = false
- private var _coloringMode: ColoringMode? = null
- var coloringMode: ColoringMode
- get() {
- return requireNotNull(_coloringMode) { "Coloring mode is not initialized yet" }
- }
- set(value) {
- coloringModeInitialized = true
- if (_coloringMode == value) {
- return
- }
- _coloringMode = value
- refreshProgressDrawable()
- refreshThumb()
- }
-
- init {
- init(attrs)
- }
-
- private fun init(attrs: AttributeSet? = null) {
- val typedArray = context.theme.obtainStyledAttributes(
- attrs,
- R.styleable.RGBColorPickerSeekBar,
- 0,
- 0
- )
-
- mode = Mode.values()[typedArray.getInteger(
- R.styleable.RGBColorPickerSeekBar_rgbMode,
- DEFAULT_MODE.ordinal
- )]
- coloringMode = ColoringMode.values()[typedArray.getInteger(
- R.styleable.RGBColorPickerSeekBar_rgbColoringMode,
- DEFAULT_COLORING_MODE.ordinal
- )]
-
- typedArray.recycle()
- }
-
- override fun setMax(max: Int) {
- if (modeInitialized && max != mode.absoluteProgress) {
- throw IllegalArgumentException("Current mode supports ${mode.absoluteProgress} max value only, was $max")
- }
- super.setMax(max)
- }
-
- override fun onUpdateColorFrom(color: IntegerRGBColor, value: IntegerRGBColor) {
- color.setFrom(value)
- }
-
- override fun onRefreshProperties() {
- if (!modeInitialized) {
- return
- }
- max = mode.maxProgress
- }
-
- override fun onRefreshProgressDrawable(progressDrawable: LayerDrawable) {
- if (!coloringModeInitialized || !modeInitialized) {
- return
- }
-
- (progressDrawable.getDrawable(0) as GradientDrawable).colors =
- when (coloringMode) {
- ColoringMode.PURE_COLOR, ColoringMode.PLAIN_COLOR -> mode.coloringModeCheckpointsMap[coloringMode]
- ColoringMode.OUTPUT_COLOR -> when (mode) {
- Mode.MODE_R -> TODO()
- Mode.MODE_G -> TODO()
- Mode.MODE_B -> TODO()
- }
- }
- }
-
- override fun onRefreshThumb(thumbColoringDrawables: Set) {
- thumbColoringDrawables.forEach {
- when (it) {
- is GradientDrawable -> {
- paintThumbStroke(it)
- }
- is LayerDrawable -> {
- paintThumbStroke(it.getDrawable(0) as GradientDrawable)
- }
- }
- }
- }
-
- override fun onRefreshColorFromProgress(color: IntegerRGBColor, progress: Int): Boolean {
- if (!modeInitialized) {
- return false
- }
-
- return when (mode) {
- Mode.MODE_R -> {
- val currentH = color.intR
- if (currentH != progress) {
- color.intR = progress
- true
- } else {
- false
- }
- }
- Mode.MODE_G -> {
- val currentS = color.intG
- if (currentS != progress) {
- color.intG = progress
- true
- } else {
- false
- }
- }
- Mode.MODE_B -> {
- val currentL = color.intB
- if (currentL != progress) {
- color.intB = progress
- true
- } else {
- false
- }
- }
- }
- }
-
- override fun onRefreshProgressFromColor(color: IntegerRGBColor): Int? {
- if (!modeInitialized) {
- return null
- }
-
- return when (mode) {
- Mode.MODE_R -> {
- color.intR
- }
- Mode.MODE_G -> {
- color.intG
- }
- Mode.MODE_B -> {
- color.intB
- }
- }
- }
-
- private fun paintThumbStroke(drawable: GradientDrawable) {
- if (!coloringModeInitialized || !modeInitialized) {
- return
- }
-
- val currentProgress = progress
- drawable.setStroke(
- thumbStrokeWidthPx,
- when (coloringMode) {
- ColoringMode.PURE_COLOR, ColoringMode.PLAIN_COLOR -> {
- val checkpoints = requireNotNull(mode.coloringModeCheckpointsMap[coloringMode])
- ColorUtils.blendARGB(
- checkpoints.first(),
- checkpoints.last(),
- currentProgress / mode.maxProgress.toFloat()
- )
- }
- ColoringMode.OUTPUT_COLOR -> when (mode) {
- Mode.MODE_R -> TODO()
- Mode.MODE_G -> TODO()
- Mode.MODE_B -> TODO()
- }
- }
- )
- }
-
- enum class ColoringMode {
- PURE_COLOR,
- OUTPUT_COLOR,
- PLAIN_COLOR,
- }
-
- enum class Mode(
- override val minProgress: Int,
- override val maxProgress: Int,
- val coloringModeCheckpointsMap: HashMap,
- @StringRes val nameStringResId: Int
- ) : ColorSeekBar.Mode {
- MODE_R(
- IntegerRGBColor.Component.R.minValue,
- IntegerRGBColor.Component.R.maxValue,
- hashMapOf(
- ColoringMode.PURE_COLOR to intArrayOf(
- Color.BLACK,
- Color.RED
- ),
- ColoringMode.PLAIN_COLOR to intArrayOf(
- Color.RED,
- Color.RED
- )
- ),
- R.string.title_red
- ),
- MODE_G(
- IntegerRGBColor.Component.G.minValue,
- IntegerRGBColor.Component.G.maxValue,
- hashMapOf(
- ColoringMode.PURE_COLOR to intArrayOf(
- Color.BLACK,
- Color.GREEN
- ),
- ColoringMode.PLAIN_COLOR to intArrayOf(
- Color.GREEN,
- Color.GREEN
- )
- ),
- R.string.title_green
- ),
- MODE_B(
- IntegerRGBColor.Component.B.minValue,
- IntegerRGBColor.Component.B.maxValue,
- hashMapOf(
- ColoringMode.PURE_COLOR to intArrayOf(
- Color.BLACK,
- Color.BLUE
- ),
- ColoringMode.PLAIN_COLOR to intArrayOf(
- Color.BLUE,
- Color.BLUE
- )
- ),
- R.string.title_blue
- )
- }
-
- companion object {
- private val DEFAULT_MODE = Mode.MODE_R
- private val DEFAULT_COLORING_MODE = ColoringMode.PURE_COLOR
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/util/ArrayExtensions.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/util/ArrayExtensions.kt
deleted file mode 100644
index 5b67915..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/util/ArrayExtensions.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package codes.side.andcolorpicker.util
-
-inline fun IntArray.mapToIntArray(
- destination: IntArray,
- transform: (Int) -> Int
-): IntArray {
- forEachIndexed { index, t ->
- destination[index] = transform(t)
- }
- return destination
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/util/BooleanExtensions.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/util/BooleanExtensions.kt
deleted file mode 100644
index a45b84a..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/util/BooleanExtensions.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package codes.side.andcolorpicker.util
-
-import kotlin.reflect.KMutableProperty0
-
-fun KMutableProperty0.marker(block: () -> Unit) {
- set(true)
- block.invoke()
- set(false)
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/picker/ColorSeekBar.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/picker/ColorSeekBar.kt
deleted file mode 100644
index 9e8bbc9..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/picker/ColorSeekBar.kt
+++ /dev/null
@@ -1,518 +0,0 @@
-package codes.side.andcolorpicker.view.picker
-
-import android.animation.ObjectAnimator
-import android.content.Context
-import android.graphics.drawable.*
-import android.os.Build
-import android.util.AttributeSet
-import android.util.Log
-import android.util.StateSet
-import android.view.Gravity
-import android.widget.SeekBar
-import androidx.appcompat.widget.AppCompatSeekBar
-import codes.side.andcolorpicker.R
-import codes.side.andcolorpicker.converter.ColorConverter
-import codes.side.andcolorpicker.converter.ColorConverterHub
-import codes.side.andcolorpicker.model.Color
-import codes.side.andcolorpicker.model.factory.ColorFactory
-import codes.side.andcolorpicker.util.marker
-
-// TODO: Support alpha on that level?
-// TODO: Make color not generic, but bridge component
-// CONTRACT: Make sure to place all needed state checks on virtual methods and refresh in init if needed
-@Suppress(
- "ConstantConditionIf",
- "LeakingThis"
-)
-abstract class ColorSeekBar @JvmOverloads constructor(
- private val colorFactory: ColorFactory,
- context: Context,
- attrs: AttributeSet? = null,
- defStyle: Int = R.attr.seekBarStyle
-) :
- AppCompatSeekBar(
- context,
- attrs,
- defStyle
- ),
- SeekBar.OnSeekBarChangeListener {
-
- // TODO: Revisit factory-based approach
- private val _pickedColor: C = colorFactory.create()
- var pickedColor: C
- get() {
- return colorFactory.createColorFrom(_pickedColor)
- }
- set(value) {
- if (DEBUG) {
- Log.d(
- TAG,
- "currentColor set() called on $this with $value"
- )
- }
- if (_pickedColor == value) {
- return
- }
- updateInternalPickedColorFrom(value)
- refreshProgressFromCurrentColor()
- refreshProgressDrawable()
- refreshThumb()
- notifyListenersOnColorChanged()
- }
-
- protected val internalPickedColor: C
- get() {
- return _pickedColor
- }
-
- protected open val colorConverter: ColorConverter
- get() {
- return ColorConverterHub.getConverterByKey(internalPickedColor.colorKey)
- }
-
- var notifyListeners = true
-
- // Dirty hack to stop onProgressChanged while playing with min/max
- private var minUpdating = false
- private var maxUpdating = false
-
- private val colorPickListeners = hashSetOf, C>>()
- private lateinit var thumbDrawable: GradientDrawable
- private lateinit var thumbObjectAnimator: ObjectAnimator
-
- // TODO: Rename
- private val thumbColoringDrawables = hashSetOf()
-
- protected val thumbStrokeWidthPx by lazy {
- resources.getDimensionPixelOffset(R.dimen.acp_thumb_stroke_width)
- }
-
- init {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- splitTrack = false
- }
-
- setOnSeekBarChangeListener(this)
-
- setupBackground()
- setupProgressDrawable()
- setupThumb()
-
- refreshProperties()
- refreshProgressFromCurrentColor()
- refreshProgressDrawable()
- refreshThumb()
- }
-
- /*
- * Setups views's background drawable. Adjusts initial thumb ripple size.
- */
- private fun setupBackground() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- background?.let { originalBackground ->
- background = originalBackground.mutate().also {
- if (it is RippleDrawable) {
- // TODO: Set ripple size for pre-M too
- // TODO: Make ripple size configurable?
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- val rippleSizePx = resources.getDimensionPixelOffset(R.dimen.acp_thumb_ripple_radius)
- it.radius = rippleSizePx
- }
- }
- }
- }
- }
- }
-
- private fun setupProgressDrawable() {
- if (DEBUG) {
- Log.d(
- TAG,
- "setupProgressDrawable() called on $this"
- )
- }
-
- val progressPaddingPx = resources.getDimensionPixelOffset(R.dimen.acp_seek_progress_padding)
- val progressHeightPx = resources.getDimensionPixelOffset(R.dimen.acp_seek_progress_height)
-
- val layers = onSetupProgressDrawableLayers(arrayOf())
- progressDrawable = LayerDrawable(
- layers
- ).also {
- // We're limited to insets on API 21
- // Migrate to layer height / padding on API 23+
- // TODO: Allow to configure whether to use insets or not
- layers.forEachIndexed { index, _ ->
- it.setLayerInset(
- index,
- progressPaddingPx,
- progressPaddingPx,
- progressPaddingPx,
- progressPaddingPx
- )
- }
- }
- }
-
- // Immutable array to limit hacks
- // Inherited layers may be not fully built by this method invocation time
- protected abstract fun onSetupProgressDrawableLayers(layers: Array): Array
-
- private fun setupThumb() {
- val backgroundPaddingPx = resources.getDimensionPixelOffset(R.dimen.acp_seek_progress_padding)
- val thumbFullSizePx = resources.getDimensionPixelOffset(R.dimen.acp_thumb_size_full)
-
- thumbDrawable = GradientDrawable().also {
- it.setColor(android.graphics.Color.WHITE)
- it.shape = GradientDrawable.OVAL
- it.setSize(
- thumbFullSizePx,
- thumbFullSizePx
- )
- }
-
- thumbColoringDrawables.add(thumbDrawable)
-
- thumb = ScaleDrawable(
- StateListDrawable().also {
- it.addState(
- StateSet.WILD_CARD,
- thumbDrawable
- )
- },
- Gravity.CENTER,
- 1f,
- 1f
- ).also {
- it.level = THUMB_DRAWABLE_LEVEL_DEFAULT
- }
-
- thumbObjectAnimator = ObjectAnimator.ofInt(
- thumb,
- "level",
- THUMB_DRAWABLE_LEVEL_DEFAULT,
- THUMB_DRAWABLE_LEVEL_PRESSED
- ).also {
- it.duration = THUMB_ANIM_DURATION
- }
-
- thumbOffset -= backgroundPaddingPx / 2
- }
-
- override fun setMin(min: Int) {
- if (min != 0) {
- throw IllegalArgumentException("Current mode supports 0 min value only, was $min")
- }
- ::minUpdating.marker {
- super.setMin(min)
- }
- }
-
- override fun setMax(max: Int) {
- ::maxUpdating.marker {
- super.setMax(max)
- }
- }
-
- /*
- * Silently updates internal picked color from provided value.
- */
- private fun updateInternalPickedColorFrom(value: C) {
- if (DEBUG) {
- Log.d(
- TAG,
- "updateInternalCurrentColorFrom() called on $this"
- )
- }
-
- onUpdateColorFrom(
- internalPickedColor,
- value
- )
- }
-
- protected abstract fun onUpdateColorFrom(color: C, value: C)
-
- /*
- * Refresh or set basic SeekBar properties like min/max.
- */
- protected fun refreshProperties() {
- if (DEBUG) {
- Log.d(
- TAG,
- "refreshProperties() called on $this"
- )
- }
-
- onRefreshProperties()
- }
-
- protected abstract fun onRefreshProperties()
-
- /*
- * Synchronizes progress from picked color.
- */
- protected fun refreshProgressFromCurrentColor() {
- if (DEBUG) {
- Log.d(
- TAG,
- "refreshProgressFromCurrentColor() called on $this"
- )
- }
-
- val result = onRefreshProgressFromColor(internalPickedColor)
- result?.let {
- progress = it
- }
- }
-
- protected abstract fun onRefreshProgressFromColor(color: C): Int?
-
- /*
- * Synchronizes internal picked color from progress while bypassing public API
- * pickedColor setter and consequent calls.
- */
- private fun refreshInternalPickedColorFromProgress() {
- if (DEBUG) {
- Log.d(
- TAG,
- "refreshInternalCurrentColorFromProgress() called on $this"
- )
- }
-
- val changed = onRefreshColorFromProgress(
- internalPickedColor,
- progress
- )
-
- if (changed) {
- notifyListenersOnColorChanged()
- }
- }
-
- protected abstract fun onRefreshColorFromProgress(color: C, progress: Int): Boolean
-
- /*
- * Refreshes SeekBar's progress drawable according to derived class details.
- * CONTRACT: Derived class is responsible for progressDrawable changes if needed.
- */
- protected fun refreshProgressDrawable() {
- if (DEBUG) {
- Log.d(
- TAG,
- "refreshProgressDrawable() called on $this"
- )
- }
-
- onRefreshProgressDrawable(progressDrawable as LayerDrawable)
- }
-
- protected abstract fun onRefreshProgressDrawable(progressDrawable: LayerDrawable)
-
- /**
- * Refreshes SeekBar's thumb drawable according to derived class details.
- * CONTRACT: Should paint GradientDrawable and first layer of LayerDrawable
- */
- protected fun refreshThumb() {
- if (DEBUG) {
- Log.d(
- TAG,
- "refreshThumb() called on $this"
- )
- }
-
- onRefreshThumb(thumbColoringDrawables)
- }
-
- protected abstract fun onRefreshThumb(thumbColoringDrawables: Set)
-
- fun addListener(listener: OnColorPickListener, C>) {
- colorPickListeners.add(listener)
- }
-
- fun removeListener(listener: OnColorPickListener, C>) {
- colorPickListeners.remove(listener)
- }
-
- fun clearListeners() {
- colorPickListeners.clear()
- }
-
- // TODO: Add (mask) delegating OnSeekBarChangeListener
- final override fun setOnSeekBarChangeListener(l: OnSeekBarChangeListener?) {
- if (l != this) {
- throw IllegalStateException("Custom OnSeekBarChangeListener not supported yet")
- }
- super.setOnSeekBarChangeListener(l)
- }
-
- private fun notifyListenersOnColorChanged() {
- if (!notifyListeners) {
- if (DEBUG) {
- Log.d(
- TAG,
- "Listeners silenced, but notifyListenersOnColorChanged called"
- )
- }
- return
- }
-
- colorPickListeners.forEach {
- it.onColorChanged(
- this,
- pickedColor,
- progress
- )
- }
- }
-
- private fun notifyListenersOnColorPicking(fromUser: Boolean) {
- if (!notifyListeners) {
- if (DEBUG) {
- Log.d(
- TAG,
- "Listeners silenced, but notifyListenersOnColorPicking called"
- )
- }
- return
- }
-
- colorPickListeners.forEach {
- it.onColorPicking(
- this,
- pickedColor,
- progress,
- fromUser
- )
- }
- }
-
- private fun notifyListenersOnColorPicked(fromUser: Boolean) {
- if (!notifyListeners) {
- if (DEBUG) {
- Log.d(
- TAG,
- "Listeners silenced, but notifyListenersOnColorPicked called"
- )
- }
- return
- }
-
- colorPickListeners.forEach {
- it.onColorPicked(
- this,
- pickedColor,
- progress,
- fromUser
- )
- }
- }
-
- // TODO: Revisit
- override fun onProgressChanged(
- seekBar: SeekBar,
- progress: Int,
- fromUser: Boolean
- ) {
- if (minUpdating || maxUpdating) {
- return
- }
-
- refreshInternalPickedColorFromProgress()
- refreshProgressDrawable()
- refreshThumb()
- notifyListenersOnColorPicking(fromUser)
-
- if (!fromUser) {
- notifyListenersOnColorPicked(fromUser)
- }
- }
-
- override fun onStartTrackingTouch(seekBar: SeekBar) {
- thumbObjectAnimator.setIntValues(
- thumb.level,
- THUMB_DRAWABLE_LEVEL_PRESSED
- )
- thumbObjectAnimator.start()
- }
-
- override fun onStopTrackingTouch(seekBar: SeekBar) {
- thumbObjectAnimator.setIntValues(
- thumb.level,
- THUMB_DRAWABLE_LEVEL_DEFAULT
- )
- thumbObjectAnimator.start()
- notifyListenersOnColorPicked(true)
- }
-
- interface Mode {
- val minProgress: Int
- val maxProgress: Int
- }
-
- val Mode.absoluteProgress: Int
- get() {
- return maxProgress - minProgress
- }
-
- // TODO: Rename
- interface OnColorPickListener, C : Color> {
- fun onColorPicking(
- picker: S,
- color: C,
- value: Int,
- fromUser: Boolean
- )
-
- fun onColorPicked(
- picker: S,
- color: C,
- value: Int,
- fromUser: Boolean
- )
-
- fun onColorChanged(
- picker: S,
- color: C,
- value: Int
- )
- }
-
- open class DefaultOnColorPickListener, C : Color> :
- OnColorPickListener {
- override fun onColorPicking(
- picker: S,
- color: C,
- value: Int,
- fromUser: Boolean
- ) {
-
- }
-
- override fun onColorPicked(
- picker: S,
- color: C,
- value: Int,
- fromUser: Boolean
- ) {
-
- }
-
- override fun onColorChanged(
- picker: S,
- color: C,
- value: Int
- ) {
-
- }
- }
-
- companion object {
- private const val TAG = "ColorSeekBar"
- private const val DEBUG = false
-
- private const val THUMB_DRAWABLE_LEVEL_DEFAULT = 8000
- private const val THUMB_DRAWABLE_LEVEL_PRESSED = 10000
- private const val THUMB_ANIM_DURATION = 150L
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/picker/GradientColorSeekBar.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/picker/GradientColorSeekBar.kt
deleted file mode 100644
index cccd8b8..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/picker/GradientColorSeekBar.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package codes.side.andcolorpicker.view.picker
-
-import android.content.Context
-import android.graphics.drawable.Drawable
-import android.graphics.drawable.GradientDrawable
-import android.util.AttributeSet
-import codes.side.andcolorpicker.R
-import codes.side.andcolorpicker.model.Color
-import codes.side.andcolorpicker.model.factory.ColorFactory
-
-abstract class GradientColorSeekBar @JvmOverloads constructor(
- colorFactory: ColorFactory,
- context: Context,
- attrs: AttributeSet? = null,
- defStyle: Int = R.attr.seekBarStyle
-) : ColorSeekBar(
- colorFactory,
- context,
- attrs,
- defStyle
-) {
-
- override fun onSetupProgressDrawableLayers(layers: Array): Array {
- return layers.toMutableList().also { list ->
- list.add(
- GradientDrawable().also {
- it.orientation = GradientDrawable.Orientation.LEFT_RIGHT
- it.cornerRadius =
- resources.getDimensionPixelOffset(R.dimen.acp_seek_progress_corner_radius)
- .toFloat()
- it.shape = GradientDrawable.RECTANGLE
- // TODO: Make stroke configurable
- //it.setStroke(
- // 4,
- // Color.rgb(
- // 192,
- // 192,
- // 192
- // )
- //)
- }
- )
- }.toTypedArray()
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/picker/Listeners.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/picker/Listeners.kt
deleted file mode 100644
index 041e7dc..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/picker/Listeners.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package codes.side.andcolorpicker.view.picker
-
-import codes.side.andcolorpicker.model.IntegerCMYKColor
-import codes.side.andcolorpicker.model.IntegerHSLColor
-import codes.side.andcolorpicker.model.IntegerLABColor
-import codes.side.andcolorpicker.model.IntegerRGBColor
-
-typealias OnIntegerRGBColorPickListener = ColorSeekBar.DefaultOnColorPickListener, IntegerRGBColor>
-
-typealias OnIntegerCMYKColorPickListener = ColorSeekBar.DefaultOnColorPickListener, IntegerCMYKColor>
-
-typealias OnIntegerHSLColorPickListener = ColorSeekBar.DefaultOnColorPickListener, IntegerHSLColor>
-
-typealias OnIntegerLABColorPickListener = ColorSeekBar.DefaultOnColorPickListener, IntegerLABColor>
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/set/ColorPickerSeekBarSet.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/set/ColorPickerSeekBarSet.kt
deleted file mode 100644
index f269a0f..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/set/ColorPickerSeekBarSet.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package codes.side.andcolorpicker.view.set
-
-import android.content.Context
-import android.util.AttributeSet
-import android.widget.LinearLayout
-import android.widget.TextView
-import androidx.annotation.StringRes
-import androidx.core.widget.TextViewCompat
-import codes.side.andcolorpicker.group.PickerGroup
-import codes.side.andcolorpicker.model.Color
-
-abstract class ColorPickerSeekBarSet @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyle: Int = 0
-) : LinearLayout(
- context,
- attrs,
- defStyle
-) {
-
- val pickerGroup = PickerGroup()
-
- init {
- orientation = VERTICAL
- init(attrs)
- }
-
- private fun init(attrs: AttributeSet? = null) {
- //val typedArray = context.theme.obtainStyledAttributes(
- // attrs,
- // R.styleable.ColorPickerSeekBarSet,
- // 0,
- // 0
- //)
-
- //typedArray.recycle()
- }
-
- protected fun addLabel(@StringRes titleResId: Int) {
- addView(
- TextView(
- context
- ).also {
- TextViewCompat.setTextAppearance(
- it,
- defaultTextAppearance
- )
- it.setText(titleResId)
- }
- )
- }
-
- companion object {
- private const val TAG = "ColorPickerSeekBarSet"
- private const val defaultTextAppearance = android.R.style.TextAppearance_Material_Caption
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/set/RGBColorPickerSeekBarSet.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/set/RGBColorPickerSeekBarSet.kt
deleted file mode 100644
index 029eac5..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/set/RGBColorPickerSeekBarSet.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package codes.side.andcolorpicker.view.set
-
-import android.content.Context
-import android.util.AttributeSet
-import codes.side.andcolorpicker.model.IntegerRGBColor
-import codes.side.andcolorpicker.rgb.RGBColorPickerSeekBar
-
-class RGBColorPickerSeekBarSet @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyle: Int = 0
-) : ColorPickerSeekBarSet(
- context,
- attrs,
- defStyle
-) {
-
- init {
- RGBColorPickerSeekBar.Mode.values().forEach { rgbMode ->
- addLabel(rgbMode.nameStringResId)
- val picker =
- RGBColorPickerSeekBar(
- context
- ).also {
- it.mode = rgbMode
- }
- pickerGroup.registerPicker(picker)
- addView(picker)
- }
- }
-}
diff --git a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/swatch/SwatchView.kt b/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/swatch/SwatchView.kt
deleted file mode 100644
index b22194e..0000000
--- a/andcolorpicker/src/main/java/codes/side/andcolorpicker/view/swatch/SwatchView.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package codes.side.andcolorpicker.view.swatch
-
-import android.content.Context
-import android.graphics.drawable.ColorDrawable
-import android.graphics.drawable.LayerDrawable
-import android.util.AttributeSet
-import android.view.View
-import androidx.annotation.ColorInt
-import androidx.core.content.ContextCompat
-import androidx.core.graphics.drawable.DrawableCompat
-import codes.side.andcolorpicker.R
-import codes.side.andcolorpicker.converter.ColorConverterHub
-import codes.side.andcolorpicker.model.Color
-
-class SwatchView @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
-) : View(
- context,
- attrs,
- defStyleAttr
-) {
-
- private val patternDrawable =
- requireNotNull(
- ContextCompat.getDrawable(
- context,
- R.drawable.bg_transparency_pattern
- )
- )
-
- init {
- background = LayerDrawable(
- arrayOf(
- patternDrawable,
- ColorDrawable()
- )
- )
- }
-
- fun setSwatchColor(color: Color) {
- ((background as LayerDrawable).getDrawable(1) as ColorDrawable).color =
- ColorConverterHub.getConverterByKey(color.colorKey).convertToColorInt(color)
- }
-
- fun setSwatchPatternTint(@ColorInt tintColor: Int) {
- DrawableCompat.setTint(
- patternDrawable,
- tintColor
- )
- }
-}
diff --git a/andcolorpicker/src/main/res/drawable-nodpi/bg_transparency_tile.png b/andcolorpicker/src/main/res/drawable-nodpi/bg_transparency_tile.png
deleted file mode 100644
index 7bb5f05..0000000
Binary files a/andcolorpicker/src/main/res/drawable-nodpi/bg_transparency_tile.png and /dev/null differ
diff --git a/andcolorpicker/src/main/res/drawable-nodpi/bg_transparency_tile_horizontal.png b/andcolorpicker/src/main/res/drawable-nodpi/bg_transparency_tile_horizontal.png
deleted file mode 100644
index 1cd05fb..0000000
Binary files a/andcolorpicker/src/main/res/drawable-nodpi/bg_transparency_tile_horizontal.png and /dev/null differ
diff --git a/andcolorpicker/src/main/res/drawable/bg_transparency_pattern.xml b/andcolorpicker/src/main/res/drawable/bg_transparency_pattern.xml
deleted file mode 100644
index e83f8b4..0000000
--- a/andcolorpicker/src/main/res/drawable/bg_transparency_pattern.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
diff --git a/andcolorpicker/src/main/res/drawable/thumb.xml b/andcolorpicker/src/main/res/drawable/thumb.xml
deleted file mode 100644
index 7c0f600..0000000
--- a/andcolorpicker/src/main/res/drawable/thumb.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
- -
-
-
-
-
-
- -
-
-
-
-
-
-
diff --git a/andcolorpicker/src/main/res/layout/layout_dialog_hsla.xml b/andcolorpicker/src/main/res/layout/layout_dialog_hsla.xml
deleted file mode 100644
index 0b35587..0000000
--- a/andcolorpicker/src/main/res/layout/layout_dialog_hsla.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
diff --git a/andcolorpicker/src/main/res/layout/layout_hsla_group.xml b/andcolorpicker/src/main/res/layout/layout_hsla_group.xml
deleted file mode 100644
index c62f94c..0000000
--- a/andcolorpicker/src/main/res/layout/layout_hsla_group.xml
+++ /dev/null
@@ -1,100 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/andcolorpicker/src/main/res/values/attrs.xml b/andcolorpicker/src/main/res/values/attrs.xml
deleted file mode 100644
index a00cf58..0000000
--- a/andcolorpicker/src/main/res/values/attrs.xml
+++ /dev/null
@@ -1,48 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/andcolorpicker/src/main/res/values/dimen.xml b/andcolorpicker/src/main/res/values/dimen.xml
deleted file mode 100644
index 7e65a35..0000000
--- a/andcolorpicker/src/main/res/values/dimen.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- 4dp
- 5dp
- 10dp
- 4dp
- 30dp
- 24dp
- 24dp
-
diff --git a/andcolorpicker/src/main/res/values/strings.xml b/andcolorpicker/src/main/res/values/strings.xml
deleted file mode 100644
index 22fc4d8..0000000
--- a/andcolorpicker/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
- Hue
- Saturation
- Lightness
- Alpha
- Cyan
- Magenta
- Yellow
- Key
- Red
- Green
- Blue
- [WIP] Pick a color
- Pick
- Cancel
- For demonstration purposes only
-
diff --git a/andcolorpicker/src/test/java/codes/side/andcolorpicker/ExampleUnitTest.kt b/andcolorpicker/src/test/java/codes/side/andcolorpicker/ExampleUnitTest.kt
deleted file mode 100644
index d39e1c2..0000000
--- a/andcolorpicker/src/test/java/codes/side/andcolorpicker/ExampleUnitTest.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package codes.side.andcolorpicker
-
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(
- 4,
- 2 + 2
- )
- }
-}
diff --git a/app/.gitignore b/app/.gitignore
deleted file mode 100644
index 796b96d..0000000
--- a/app/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index f59f160..0000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,75 +0,0 @@
-plugins {
- id "com.github.ben-manes.versions" version "0.38.0"
-}
-
-apply plugin: "com.android.application"
-apply plugin: "kotlin-android"
-apply plugin: "kotlin-android-extensions"
-apply plugin: "com.google.android.gms.oss-licenses-plugin"
-
-android {
- compileSdkVersion 30
- buildToolsVersion "29.0.3"
-
- defaultConfig {
- applicationId "codes.side.andcolorpicker.app"
- minSdkVersion 19
- targetSdkVersion 30
- versionCode 1
- versionName "1.0"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- }
- lintOptions {
- abortOnError false
- }
- signingConfigs {
- release {
- storeFile file(System.getenv("KEYSTORE_FILE") ?: KEYSTORE_FILE)
- storePassword System.getenv("KEYSTORE_PASSWORD") ?: KEYSTORE_PASSWORD
- keyAlias System.getenv("KEY_ALIAS") ?: KEY_ALIAS
- keyPassword System.getenv("KEY_PASSWORD") ?: KEY_PASSWORD
- }
- }
- buildTypes {
- release {
- signingConfig signingConfigs.release
- minifyEnabled true
- proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
- }
- }
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-}
-
-dependencies {
- implementation fileTree(dir: "libs", include: ["*.jar"])
- implementation project(":andcolorpicker")
-
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
- implementation "androidx.appcompat:appcompat:1.3.0-rc01"
-
- implementation "androidx.core:core-ktx:1.6.0-alpha02"
-
- implementation "androidx.recyclerview:recyclerview:1.2.0"
- implementation "androidx.annotation:annotation:1.2.0"
- implementation "androidx.fragment:fragment-ktx:1.3.2"
- implementation "androidx.constraintlayout:constraintlayout:2.0.4"
- implementation "com.google.android.material:material:1.3.0"
- implementation "com.mikepenz:materialdrawer:8.4.0"
-
- implementation "com.mikepenz:iconics-core:5.2.8"
- implementation "com.mikepenz:material-design-icons-dx-typeface:5.0.1.2-kotlin@aar"
- implementation "com.mikepenz:fontawesome-typeface:5.9.0.2-kotlin@aar"
-
- implementation "com.mikepenz:fastadapter:5.4.1"
- implementation "com.mikepenz:fastadapter-extensions-diff:5.4.1"
-
- implementation "com.google.android.gms:play-services-oss-licenses:17.0.0"
-
- testImplementation "junit:junit:4.13.2"
- androidTestImplementation "androidx.test.ext:junit:1.1.2"
- androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0"
-}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
deleted file mode 100644
index f1b4245..0000000
--- a/app/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
diff --git a/app/src/androidTest/java/codes/side/andcolorpicker/app/ExampleInstrumentedTest.kt b/app/src/androidTest/java/codes/side/andcolorpicker/app/ExampleInstrumentedTest.kt
deleted file mode 100644
index 4606e6c..0000000
--- a/app/src/androidTest/java/codes/side/andcolorpicker/app/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package codes.side.andcolorpicker.app
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.assertEquals
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation()
- .targetContext
- assertEquals(
- "codes.side.andcolorpicker.app",
- appContext.packageName
- )
- }
-}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
deleted file mode 100644
index 62afeb1..0000000
--- a/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
deleted file mode 100644
index 3a72dbf..0000000
Binary files a/app/src/main/ic_launcher-playstore.png and /dev/null differ
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/ColorizationConsumer.kt b/app/src/main/java/codes/side/andcolorpicker/app/ColorizationConsumer.kt
deleted file mode 100644
index fa99ca3..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/ColorizationConsumer.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package codes.side.andcolorpicker.app
-
-import codes.side.andcolorpicker.model.IntegerHSLColor
-
-interface ColorizationConsumer {
- fun colorize(color: IntegerHSLColor)
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/activity/MainActivity.kt b/app/src/main/java/codes/side/andcolorpicker/app/activity/MainActivity.kt
deleted file mode 100644
index 200f5ca..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/activity/MainActivity.kt
+++ /dev/null
@@ -1,263 +0,0 @@
-package codes.side.andcolorpicker.app.activity
-
-import android.content.Intent
-import android.content.res.ColorStateList
-import android.content.res.Configuration
-import android.net.Uri
-import android.os.Build
-import android.os.Bundle
-import android.view.MenuItem
-import androidx.appcompat.app.ActionBarDrawerToggle
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.ViewCompat
-import androidx.fragment.app.Fragment
-import codes.side.andcolorpicker.app.ColorizationConsumer
-import codes.side.andcolorpicker.app.R
-import codes.side.andcolorpicker.app.fragment.*
-import codes.side.andcolorpicker.converter.toContrastColor
-import codes.side.andcolorpicker.converter.toOpaqueColorInt
-import codes.side.andcolorpicker.model.IntegerHSLColor
-import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
-import com.mikepenz.iconics.IconicsColor
-import com.mikepenz.iconics.IconicsDrawable
-import com.mikepenz.iconics.typeface.IIcon
-import com.mikepenz.iconics.typeface.library.fontawesome.FontAwesome
-import com.mikepenz.iconics.typeface.library.materialdesigndx.MaterialDesignDx
-import com.mikepenz.iconics.utils.color
-import com.mikepenz.iconics.utils.icon
-import com.mikepenz.materialdrawer.holder.ImageHolder
-import com.mikepenz.materialdrawer.holder.StringHolder
-import com.mikepenz.materialdrawer.model.PrimaryDrawerItem
-import kotlinx.android.synthetic.main.activity_main.*
-
-class MainActivity : AppCompatActivity(),
- ColorizationConsumer {
-
- companion object {
- private const val TAG = "MainActivity"
- }
-
- private lateinit var actionBarDrawerToggle: ActionBarDrawerToggle
- private val colorizeHSLColorCache = IntegerHSLColor()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_main)
- setSupportActionBar(toolbar)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- supportActionBar?.setHomeButtonEnabled(true)
-
- actionBarDrawerToggle = ActionBarDrawerToggle(
- this,
- root,
- toolbar,
- R.string.material_drawer_open,
- R.string.material_drawer_close
- )
-
- slider.apply {
- itemAdapter.add(
- Page
- .values().map { page ->
- PrimaryDrawerItem().also {
- it.tag = page
- it.identifier = page.hashCode().toLong()
- it.name = StringHolder(page.title)
- it.icon = ImageHolder(
- IconicsDrawable(this@MainActivity)
- .icon(page.icon)
- .color { IconicsColor.colorRes(R.color.colorPrimary) }
- )
- it.isSelectable = page.fragmentProducer != null
- }
- }
- )
-
- onDrawerItemClickListener = { _, drawerItem, _ ->
- navigateTo(drawerItem.tag as Page)
- false
- }
-
- setSavedInstance(savedInstanceState)
- }
-
- if (savedInstanceState == null) {
- // TODO: Get rid of hashCode-based identifiers
- slider.setSelection(
- Page.HLS_SEEK_BAR.hashCode().toLong()
- )
- }
- }
-
- private fun navigateTo(page: Page) {
- when (page) {
- Page.GITHUB -> {
- startActivity(
- Intent(
- Intent.ACTION_VIEW,
- Uri.parse("https://github.com/side-codes/andColorPicker")
- )
- )
- }
- Page.OSS -> {
- startActivity(
- Intent(
- this,
- OssLicensesMenuActivity::class.java
- )
- )
- }
- else -> {
- if (supportFragmentManager
- .findFragmentByTag(page.toString()) != null
- ) {
- return
- }
- page.fragmentProducer?.let {
- supportFragmentManager
- .beginTransaction()
- .replace(
- R.id.fragmentContainer,
- it.invoke(),
- page.toString()
- )
- .commit()
- }
- }
- }
- }
-
- override fun onConfigurationChanged(newConfig: Configuration) {
- super.onConfigurationChanged(newConfig)
- actionBarDrawerToggle.onConfigurationChanged(newConfig)
- }
-
- override fun onPostCreate(savedInstanceState: Bundle?) {
- super.onPostCreate(savedInstanceState)
- actionBarDrawerToggle.syncState()
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- else -> {
- return actionBarDrawerToggle.onOptionsItemSelected(item)
- }
- }
- }
-
- // TODO: Propose that for
- // https://github.com/mikepenz/MaterialDrawer/blob/develop/app/src/main/java/com/mikepenz/materialdrawer/app/AdvancedActivity.kt#L192
- override fun onSaveInstanceState(outState: Bundle) {
- super.onSaveInstanceState(slider.saveInstanceState(outState))
- }
-
- override fun onBackPressed() {
- if (root.isDrawerOpen(slider)) {
- root.closeDrawer(slider)
- } else {
- super.onBackPressed()
- }
- }
-
- override fun colorize(color: IntegerHSLColor) {
- val contrastColor = color.toContrastColor()
-
- // Overwrite cache for AppBar
- colorizeHSLColorCache.setFrom(color)
- colorizeHSLColorCache.floatL = colorizeHSLColorCache.floatL.coerceAtMost(0.8f)
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- appBarLayout.backgroundTintList =
- ColorStateList.valueOf(colorizeHSLColorCache.toOpaqueColorInt())
- } else {
- appBarLayout.setBackgroundColor(colorizeHSLColorCache.toOpaqueColorInt())
- }
-
- ViewCompat.setBackgroundTintList(
- appBarLayout,
- ColorStateList.valueOf(colorizeHSLColorCache.toOpaqueColorInt())
- )
-
- // Overwrite cache for StatusBar
- colorizeHSLColorCache.floatL -= 0.1f
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- window.statusBarColor = colorizeHSLColorCache.toOpaqueColorInt()
- }
-
- toolbar.setTitleTextColor(contrastColor)
- toolbar.setSubtitleTextColor(contrastColor)
-
- actionBarDrawerToggle.drawerArrowDrawable.color = contrastColor
- }
-
- enum class Page(
- val title: String,
- val icon: IIcon,
- val fragmentProducer: (() -> Fragment)? = null
- ) {
- HLS_SEEK_BAR(
- "HSL SeekBar",
- MaterialDesignDx.Icon.gmf_space_bar,
- { HSLSeekBarFragment() }
- ),
- HLS_SEEK_BAR_RV(
- "HSL SeekBar RecyclerView",
- MaterialDesignDx.Icon.gmf_list,
- { HSLSeekBarRecyclerViewFragment() }
- ),
- HLS_SEEK_BAR_GITHUB(
- "HSL SeekBar GitHub",
- FontAwesome.Icon.faw_github,
- { HSLSeekBarGithubSampleFragment() }
- ),
- HLS_PLANE(
- "HSL Plane",
- MaterialDesignDx.Icon.gmf_fullscreen,
- { WipFragment() }
- ),
- RGB_SEEK_BAR(
- "RGB SeekBar",
- MaterialDesignDx.Icon.gmf_space_bar,
- { RGBSeekBarFragment() }
- ),
- RGB_PLANE(
- "RGB Plane",
- MaterialDesignDx.Icon.gmf_fullscreen,
- { WipFragment() }
- ),
- RGB_CIRCLE(
- "RGB Circle",
- MaterialDesignDx.Icon.gmf_lens,
- { WipFragment() }
- ),
- LAB_SEEK_BAR(
- "LAB SeekBar",
- FontAwesome.Icon.faw_flask,
- { LABSeekBarFragment() }
- ),
- CMYK_SEEK_BAR(
- "CMYK SeekBar",
- MaterialDesignDx.Icon.gmf_print,
- { CMYKSeekBarFragment() }
- ),
- CMYK_SEEK_BAR_GITHUB(
- "CMYK SeekBar GitHub",
- FontAwesome.Icon.faw_github,
- { CMYKSeekBarGithubSampleFragment() }
- ),
- SWATCHES(
- "Swatches",
- MaterialDesignDx.Icon.gmf_view_comfy,
- { SwatchFragment() }
- ),
- GITHUB(
- "GitHub",
- FontAwesome.Icon.faw_github
- ),
- OSS(
- "Open Source Notices",
- MaterialDesignDx.Icon.gmf_emoji_people
- )
- }
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/activity/StandaloneActivity.kt b/app/src/main/java/codes/side/andcolorpicker/app/activity/StandaloneActivity.kt
deleted file mode 100644
index 6845115..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/activity/StandaloneActivity.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package codes.side.andcolorpicker.app.activity
-
-import android.os.Bundle
-import androidx.appcompat.app.AppCompatActivity
-import codes.side.andcolorpicker.app.R
-import kotlinx.android.synthetic.main.activity_standalone.*
-
-class StandaloneActivity : AppCompatActivity(R.layout.activity_standalone) {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- defaultSeekBar.progress = 50
- }
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/fragment/CMYKSeekBarFragment.kt b/app/src/main/java/codes/side/andcolorpicker/app/fragment/CMYKSeekBarFragment.kt
deleted file mode 100644
index dbb01b3..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/fragment/CMYKSeekBarFragment.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package codes.side.andcolorpicker.app.fragment
-
-import android.os.Bundle
-import android.view.View
-import androidx.fragment.app.Fragment
-import codes.side.andcolorpicker.app.R
-import codes.side.andcolorpicker.group.PickerGroup
-import codes.side.andcolorpicker.group.registerPickers
-import codes.side.andcolorpicker.model.IntegerCMYKColor
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-import codes.side.andcolorpicker.view.picker.OnIntegerCMYKColorPickListener
-import kotlinx.android.synthetic.main.fragment_cmyk_seek_bar.*
-
-class CMYKSeekBarFragment : Fragment(R.layout.fragment_cmyk_seek_bar) {
- companion object {
- private const val TAG = "CMYKSeekBarFragment"
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(
- view,
- savedInstanceState
- )
-
- val pickerGroup = PickerGroup().also {
- it.registerPickers(
- cyanCMYKColorPickerSeekBar,
- magentaCMYKColorPickerSeekBar,
- yellowCMYKColorPickerSeekBar,
- blackCMYKColorPickerSeekBar
- )
- }
-
- pickerGroup.addListener(
- object : OnIntegerCMYKColorPickListener() {
- override fun onColorChanged(
- picker: ColorSeekBar,
- color: IntegerCMYKColor,
- value: Int
- ) {
- swatchView.setSwatchColor(
- color
- )
- }
- }
- )
-
- pickerGroup.setColor(
- IntegerCMYKColor().also {
- it.intC = 70
- it.intM = 40
- it.intY = 20
- it.intK = 50
- }
- )
- }
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/fragment/CMYKSeekBarGithubSampleFragment.kt b/app/src/main/java/codes/side/andcolorpicker/app/fragment/CMYKSeekBarGithubSampleFragment.kt
deleted file mode 100644
index 5dee4a0..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/fragment/CMYKSeekBarGithubSampleFragment.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package codes.side.andcolorpicker.app.fragment
-
-import androidx.fragment.app.Fragment
-import codes.side.andcolorpicker.app.R
-
-class CMYKSeekBarGithubSampleFragment : Fragment(R.layout.fragment_cmyk_seek_bar_github_sample)
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/fragment/DefaultSeekBarFragment.kt b/app/src/main/java/codes/side/andcolorpicker/app/fragment/DefaultSeekBarFragment.kt
deleted file mode 100644
index 7a98cf6..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/fragment/DefaultSeekBarFragment.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package codes.side.andcolorpicker.app.fragment
-
-import androidx.fragment.app.Fragment
-import codes.side.andcolorpicker.app.R
-
-class DefaultSeekBarFragment : Fragment(R.layout.fragment_default_seek_bar)
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/fragment/HSLSeekBarFragment.kt b/app/src/main/java/codes/side/andcolorpicker/app/fragment/HSLSeekBarFragment.kt
deleted file mode 100644
index 1f7a6b6..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/fragment/HSLSeekBarFragment.kt
+++ /dev/null
@@ -1,254 +0,0 @@
-package codes.side.andcolorpicker.app.fragment
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.res.ColorStateList
-import android.graphics.Color
-import android.graphics.PorterDuff
-import android.graphics.PorterDuffColorFilter
-import android.os.Bundle
-import android.util.Log
-import android.view.View
-import android.widget.RadioButton
-import android.widget.TextView
-import androidx.core.content.ContextCompat
-import androidx.core.graphics.ColorUtils.HSLToColor
-import androidx.core.widget.CompoundButtonCompat
-import androidx.core.widget.TextViewCompat
-import androidx.fragment.app.Fragment
-import codes.side.andcolorpicker.app.ColorizationConsumer
-import codes.side.andcolorpicker.app.R
-import codes.side.andcolorpicker.app.util.firstIsInstanceOrNull
-import codes.side.andcolorpicker.converter.*
-import codes.side.andcolorpicker.dialogs.ColorPickerDialogFragment
-import codes.side.andcolorpicker.group.PickerGroup
-import codes.side.andcolorpicker.group.registerPickers
-import codes.side.andcolorpicker.hsl.HSLColorPickerSeekBar
-import codes.side.andcolorpicker.model.IntegerHSLColor
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-import codes.side.andcolorpicker.view.picker.OnIntegerHSLColorPickListener
-import com.google.android.material.button.MaterialButton
-import com.mikepenz.iconics.IconicsDrawable
-import com.mikepenz.iconics.IconicsSize
-import com.mikepenz.iconics.typeface.library.materialdesigndx.MaterialDesignDx
-import com.mikepenz.iconics.utils.icon
-import com.mikepenz.iconics.utils.padding
-import com.mikepenz.iconics.utils.size
-import kotlinx.android.synthetic.main.fragment_hsl_seek_bar.*
-
-class HSLSeekBarFragment : Fragment(R.layout.fragment_hsl_seek_bar) {
- companion object {
- private const val TAG = "HSLSeekBarFragment"
- private const val colorTextViewIconSizeDp = 24
- private const val colorTextViewIconPaddingDp = 6
- }
-
- private val colorfulViews = hashSetOf()
- private val pickerGroup =
- PickerGroup()
- private var colorizationConsumer: ColorizationConsumer? = null
- private val colorizeHSLColorCache = IntegerHSLColor()
-
- fun IntegerHSLColor.toHex(): String {
- val color = floatArrayOf(floatH, floatS, floatL)
- val intColor = HSLToColor(color)
- val red: Int = Color.red(intColor)
- val green: Int = Color.green(intColor)
- val blue: Int = Color.blue(intColor)
- return String.format("#%02x%02x%02x", red, green, blue)
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(
- view,
- savedInstanceState
- )
-
- setupIcons()
-
- // API 19 backward-compatible way
- // Set it directly for newer versions:
- // android:background="@drawable/bg_transparency_pattern"
- // android:backgroundTint="@color/colorTransparencyTileTint"
- colorContainerFrameLayout.background =
- requireNotNull(
- ContextCompat.getDrawable(
- requireContext(),
- R.drawable.bg_transparency_pattern
- )
- )
- .mutate()
- .also {
- it.colorFilter = PorterDuffColorFilter(
- ContextCompat.getColor(
- requireContext(),
- R.color.colorTransparencyTileTint
- ),
- PorterDuff.Mode.SRC_ATOP
- )
- }
-
- colorfulViews.add(hueRadioButton)
- colorfulViews.add(saturationRadioButton)
- colorfulViews.add(lightnessRadioButton)
- colorfulViews.add(pureRadioButton)
- colorfulViews.add(outputRadioButton)
- colorfulViews.add(colorContainerFrameLayout)
- colorfulViews.add(colorTextView)
- colorfulViews.add(setRandomColorButton)
- colorfulViews.add(showDialogButton)
-
- pickerGroup.addListener(
- object : OnIntegerHSLColorPickListener() {
- override fun onColorPicked(
- picker: ColorSeekBar,
- color: IntegerHSLColor,
- value: Int,
- fromUser: Boolean
- ) {
- super.onColorPicked(
- picker,
- color,
- value,
- fromUser
- )
-
- val colorHex = color.toHex()
- Log.d("HSK", colorHex)
- }
- override fun onColorChanged(
- picker: ColorSeekBar,
- color: IntegerHSLColor,
- value: Int
- ) {
- colorize(color)
- }
- }
- )
-
- pickerGroup.registerPickers(
- hueColorPickerSeekBar,
- saturationColorPickerSeekBar,
- lightnessColorPickerSeekBar,
- alphaColorPickerSeekBar,
- dynamicColorPickerSeekBar
- )
-
- randomizePickedColor()
-
- val radioColorModelsMap = hashMapOf(
- R.id.hueRadioButton to HSLColorPickerSeekBar.Mode.MODE_HUE,
- R.id.saturationRadioButton to HSLColorPickerSeekBar.Mode.MODE_SATURATION,
- R.id.lightnessRadioButton to HSLColorPickerSeekBar.Mode.MODE_LIGHTNESS
- )
- colorModelRadioGroup.setOnCheckedChangeListener { _, checkedId ->
- dynamicColorPickerSeekBar.mode = requireNotNull(radioColorModelsMap[checkedId])
- }
-
- val radioColoringModesMap = hashMapOf(
- R.id.pureRadioButton to HSLColorPickerSeekBar.ColoringMode.PURE_COLOR,
- R.id.outputRadioButton to HSLColorPickerSeekBar.ColoringMode.OUTPUT_COLOR
- )
- coloringModeRadioGroup.setOnCheckedChangeListener { _, checkedId ->
- pickerGroup.forEach {
- (it as? HSLColorPickerSeekBar)?.coloringMode =
- requireNotNull(radioColoringModesMap[checkedId])
- }
- }
-
- setRandomColorButton.setOnClickListener {
- randomizePickedColor()
- }
-
- showDialogButton.setOnClickListener {
- ColorPickerDialogFragment().show(
- childFragmentManager,
- null
- )
- }
- }
-
- private fun setupIcons() {
- colorTextView
- .setCompoundDrawables(
- IconicsDrawable(requireContext()).icon(MaterialDesignDx.Icon.gmf_colorize)
- .size { IconicsSize.dp(colorTextViewIconSizeDp + colorTextViewIconPaddingDp * 2) }
- .padding { IconicsSize.dp(colorTextViewIconPaddingDp) },
- null,
- null,
- null
- )
- setRandomColorButton.icon =
- IconicsDrawable(requireContext()).icon(MaterialDesignDx.Icon.gmf_invert_colors)
- showDialogButton.icon =
- IconicsDrawable(requireContext()).icon(MaterialDesignDx.Icon.gmf_dynamic_feed)
- }
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
- colorizationConsumer = arrayOf(
- parentFragment,
- activity
- ).firstIsInstanceOrNull()
- }
-
- private fun randomizePickedColor() {
- pickerGroup.setColor(
- IntegerHSLColor.createRandomColor().also {
- it.intL = 20 + it.intL / 2
- }
- )
- }
-
- @SuppressLint("SetTextI18n")
- private fun colorize(color: IntegerHSLColor) {
- val contrastColor = color.toContrastColor()
- val alphaContrastColor = color.toContrastColor(ContrastColorAlphaMode.LIGHT_BACKGROUND)
- colorizeHSLColorCache.setFrom(color)
- colorizeHSLColorCache.floatL = colorizeHSLColorCache.floatL.coerceAtMost(0.8f)
-
- val opaqueColorInt = colorizeHSLColorCache.toOpaqueColorInt()
- colorfulViews.forEach {
- when (it) {
- is MaterialButton -> {
- it.setTextColor(contrastColor)
- it.backgroundTintList = ColorStateList.valueOf(opaqueColorInt)
- it.iconTint = ColorStateList.valueOf(contrastColor)
- }
- is RadioButton -> {
- CompoundButtonCompat.setButtonTintList(
- it,
- ColorStateList.valueOf(opaqueColorInt)
- )
- }
- // Any other TextView is considered as raw color holder
- is TextView -> {
- it.setTextColor(alphaContrastColor)
- it.setBackgroundColor(color.toColorInt())
- TextViewCompat.setCompoundDrawableTintList(
- it,
- ColorStateList.valueOf(alphaContrastColor)
- )
- }
- }
- }
-
- colorTextView.text =
- "RGB: [${color.getRInt()} ${color.getGInt()} ${color.getBInt()}]\n" +
- "HEX: ${
- String.format(
- "#%06X",
- 0xFFFFFF and color.toColorInt()
- )
- }\n" +
- "HSL: [${color.intH} ${color.intS} ${color.intL}]\n" +
- "Alpha: ${
- String.format(
- "%.2f",
- color.alpha
- )
- }"
-
- colorizationConsumer?.colorize(color)
- }
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/fragment/HSLSeekBarGithubSampleFragment.kt b/app/src/main/java/codes/side/andcolorpicker/app/fragment/HSLSeekBarGithubSampleFragment.kt
deleted file mode 100644
index fa53a73..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/fragment/HSLSeekBarGithubSampleFragment.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package codes.side.andcolorpicker.app.fragment
-
-import android.graphics.Color
-import android.os.Bundle
-import android.util.Log
-import android.view.View
-import androidx.fragment.app.Fragment
-import codes.side.andcolorpicker.app.R
-import codes.side.andcolorpicker.converter.setFromColorInt
-import codes.side.andcolorpicker.group.PickerGroup
-import codes.side.andcolorpicker.group.registerPickers
-import codes.side.andcolorpicker.hsl.HSLColorPickerSeekBar.ColoringMode
-import codes.side.andcolorpicker.hsl.HSLColorPickerSeekBar.Mode
-import codes.side.andcolorpicker.model.IntegerHSLColor
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-import codes.side.andcolorpicker.view.picker.OnIntegerHSLColorPickListener
-import kotlinx.android.synthetic.main.fragment_hsl_seek_bar_github_sample.*
-
-class HSLSeekBarGithubSampleFragment : Fragment(R.layout.fragment_hsl_seek_bar_github_sample) {
- companion object {
- private const val TAG = "HSLSeekBarGithubSampleFragment"
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(
- view,
- savedInstanceState
- )
-
- // Configure picker color model programmatically
- hueColorPickerSeekBar.mode = Mode.MODE_HUE // Mode.MODE_SATURATION, Mode.MODE_LIGHTNESS
-
- // Configure coloring mode programmatically
- hueColorPickerSeekBar.coloringMode = ColoringMode.PURE_COLOR // ColoringMode.OUTPUT_COLOR
-
- // Group pickers with PickerGroup to automatically synchronize color across them
- val pickerGroup = PickerGroup().also {
- it.registerPickers(
- hueColorPickerSeekBar,
- saturationColorPickerSeekBar,
- lightnessColorPickerSeekBar,
- alphaColorPickerSeekBar
- )
- }
-
- // Get current color immediately
- Log.d(
- TAG,
- "Current color is ${hueColorPickerSeekBar.pickedColor}"
- )
-
- // Listen individual pickers or groups for changes
- pickerGroup.addListener(
- object : OnIntegerHSLColorPickListener() {
- override fun onColorChanged(
- picker: ColorSeekBar,
- color: IntegerHSLColor,
- value: Int
- ) {
- Log.d(
- TAG,
- "$color picked"
- )
- swatchView.setSwatchColor(
- color
- )
- }
- }
- )
-
- // Set desired color programmatically
- pickerGroup.setColor(
- IntegerHSLColor().also {
- it.setFromColorInt(
- Color.rgb(
- 28,
- 84,
- 187
- )
- )
- }
- )
-
- // Set color components programmatically
- hueColorPickerSeekBar.progress = 50
- }
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/fragment/HSLSeekBarRecyclerViewFragment.kt b/app/src/main/java/codes/side/andcolorpicker/app/fragment/HSLSeekBarRecyclerViewFragment.kt
deleted file mode 100644
index 412571d..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/fragment/HSLSeekBarRecyclerViewFragment.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-package codes.side.andcolorpicker.app.fragment
-
-import android.os.Bundle
-import android.view.View
-import androidx.annotation.ColorInt
-import androidx.fragment.app.Fragment
-import androidx.recyclerview.widget.LinearLayoutManager
-import codes.side.andcolorpicker.app.R
-import codes.side.andcolorpicker.converter.setFromColorInt
-import codes.side.andcolorpicker.converter.toColorInt
-import codes.side.andcolorpicker.model.IntegerHSLColor
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-import codes.side.andcolorpicker.view.picker.OnIntegerHSLColorPickListener
-import com.mikepenz.fastadapter.FastAdapter
-import com.mikepenz.fastadapter.adapters.ItemAdapter
-import com.mikepenz.fastadapter.items.AbstractItem
-import kotlinx.android.synthetic.main.fragment_hsl_seek_bar_recyclerview.*
-import kotlinx.android.synthetic.main.layout_item_hsl_seek_bar.view.*
-
-class HSLSeekBarRecyclerViewFragment : Fragment(R.layout.fragment_hsl_seek_bar_recyclerview) {
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(
- view,
- savedInstanceState
- )
-
- recyclerView.layoutManager = LinearLayoutManager(
- requireContext()
- )
-
- val adapter = ItemAdapter.items()
- val items = mutableListOf()
- repeat(100) {
- items.add(
- HSLItem(
- IntegerHSLColor.createRandomColor(true).toColorInt()
- )
- )
- }
- adapter.add(items)
-
- recyclerView.adapter = FastAdapter.with(adapter)
- }
-
- class HSLItem(@ColorInt initialColor: Int) : AbstractItem() {
- private val hslColor = IntegerHSLColor().also {
- it.setFromColorInt(initialColor)
- }
-
- class ViewHolder(itemView: View) : FastAdapter.ViewHolder(itemView) {
- private var lastBoundItem: HSLItem? = null
-
- init {
- itemView.hslSeekBar.addListener(
- object : OnIntegerHSLColorPickListener() {
- override fun onColorChanged(
- picker: ColorSeekBar,
- color: IntegerHSLColor,
- value: Int
- ) {
- lastBoundItem?.hslColor?.setFrom(color)
- colorize()
- }
- }
- )
- }
-
- override fun bindView(item: HSLItem, payloads: List) {
- lastBoundItem = item
- itemView.hslSeekBar.pickedColor = item.hslColor
- colorize()
- }
-
- override fun unbindView(item: HSLItem) {
- lastBoundItem = null
- }
-
- private fun colorize() {
- val hslColor = requireNotNull(lastBoundItem).hslColor.clone().also {
- it.floatL += 0.45f
- }
- itemView.cardView.setCardBackgroundColor(
- hslColor.toColorInt()
- )
- }
- }
-
- override val layoutRes: Int
- get() = R.layout.layout_item_hsl_seek_bar
- override val type: Int
- get() = 0
-
- override fun getViewHolder(v: View): ViewHolder {
- return ViewHolder(v)
- }
- }
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/fragment/LABSeekBarFragment.kt b/app/src/main/java/codes/side/andcolorpicker/app/fragment/LABSeekBarFragment.kt
deleted file mode 100644
index c481800..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/fragment/LABSeekBarFragment.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package codes.side.andcolorpicker.app.fragment
-
-import android.os.Bundle
-import android.view.View
-import androidx.fragment.app.Fragment
-import codes.side.andcolorpicker.app.R
-import codes.side.andcolorpicker.group.PickerGroup
-import codes.side.andcolorpicker.group.registerPickers
-import codes.side.andcolorpicker.model.IntegerLABColor
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-import codes.side.andcolorpicker.view.picker.OnIntegerLABColorPickListener
-import kotlinx.android.synthetic.main.fragment_cmyk_seek_bar.swatchView
-import kotlinx.android.synthetic.main.fragment_lab_seek_bar.*
-
-class LABSeekBarFragment : Fragment(R.layout.fragment_lab_seek_bar) {
- companion object {
- private const val TAG = "LABSeekBarFragment"
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(
- view,
- savedInstanceState
- )
-
- val pickerGroup = PickerGroup().also {
- it.registerPickers(
- lLABColorPickerSeekBar,
- aLABColorPickerSeekBar,
- bLABColorPickerSeekBar
- )
- }
-
- pickerGroup.addListener(
- object : OnIntegerLABColorPickListener() {
- override fun onColorChanged(
- picker: ColorSeekBar,
- color: IntegerLABColor,
- value: Int
- ) {
- swatchView.setSwatchColor(
- color
- )
- }
- }
- )
-
- pickerGroup.setColor(
- IntegerLABColor().also {
- it.intL = 60
- it.intA = 50
- it.intB = 90
- }
- )
- }
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/fragment/RGBSeekBarFragment.kt b/app/src/main/java/codes/side/andcolorpicker/app/fragment/RGBSeekBarFragment.kt
deleted file mode 100644
index aa13f37..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/fragment/RGBSeekBarFragment.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package codes.side.andcolorpicker.app.fragment
-
-import android.os.Bundle
-import android.view.View
-import androidx.fragment.app.Fragment
-import codes.side.andcolorpicker.app.R
-import codes.side.andcolorpicker.model.IntegerRGBColor
-import codes.side.andcolorpicker.rgb.RGBColorPickerSeekBar
-import codes.side.andcolorpicker.view.picker.ColorSeekBar
-import codes.side.andcolorpicker.view.picker.OnIntegerRGBColorPickListener
-import kotlinx.android.synthetic.main.fragment_rgb_seek_bar.*
-
-class RGBSeekBarFragment : Fragment(R.layout.fragment_rgb_seek_bar) {
-
- companion object {
- private const val TAG = "RGBSeekBarFragment"
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(
- view,
- savedInstanceState
- )
-
- val pickerGroup = colorPickerSeekBarSet.pickerGroup
-
- pickerGroup.addListener(
- object :
- OnIntegerRGBColorPickListener() {
- override fun onColorChanged(
- picker: ColorSeekBar,
- color: IntegerRGBColor,
- value: Int
- ) {
- swatchView.setSwatchColor(
- color
- )
- }
- }
- )
-
- pickerGroup.setColor(
- IntegerRGBColor().also {
- it.intR = 30
- it.intG = 130
- it.intB = 230
- }
- )
-
- val radioColoringModesMap = hashMapOf(
- R.id.pureRadioButton to RGBColorPickerSeekBar.ColoringMode.PURE_COLOR,
- R.id.plainRadioButton to RGBColorPickerSeekBar.ColoringMode.PLAIN_COLOR
- )
- coloringModeRadioGroup.setOnCheckedChangeListener { _, checkedId ->
- pickerGroup.forEach {
- (it as? RGBColorPickerSeekBar)?.coloringMode =
- requireNotNull(radioColoringModesMap[checkedId])
- }
- }
- }
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/fragment/SwatchFragment.kt b/app/src/main/java/codes/side/andcolorpicker/app/fragment/SwatchFragment.kt
deleted file mode 100644
index d3ec026..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/fragment/SwatchFragment.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package codes.side.andcolorpicker.app.fragment
-
-import android.graphics.Color
-import android.os.Bundle
-import android.view.View
-import androidx.core.content.ContextCompat
-import androidx.core.graphics.ColorUtils
-import androidx.fragment.app.Fragment
-import codes.side.andcolorpicker.app.R
-import codes.side.andcolorpicker.converter.setFromColorInt
-import codes.side.andcolorpicker.model.IntegerHSLColor
-import kotlinx.android.synthetic.main.fragment_swatch.*
-
-class SwatchFragment : Fragment(R.layout.fragment_swatch) {
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(
- view,
- savedInstanceState
- )
-
- swatchView.setSwatchPatternTint(
- ContextCompat.getColor(
- requireContext(),
- R.color.colorTransparencyTileTint
- )
- )
-
- swatchView.setSwatchColor(
- IntegerHSLColor().also {
- it.setFromColorInt(
- ColorUtils.setAlphaComponent(
- Color.MAGENTA,
- 128
- )
- )
- }
- )
- }
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/fragment/WipFragment.kt b/app/src/main/java/codes/side/andcolorpicker/app/fragment/WipFragment.kt
deleted file mode 100644
index 325bd8a..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/fragment/WipFragment.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package codes.side.andcolorpicker.app.fragment
-
-import androidx.fragment.app.Fragment
-import codes.side.andcolorpicker.app.R
-
-class WipFragment : Fragment(R.layout.fragment_wip)
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/util/ArrayExtensions.kt b/app/src/main/java/codes/side/andcolorpicker/app/util/ArrayExtensions.kt
deleted file mode 100644
index 7052022..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/util/ArrayExtensions.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package codes.side.andcolorpicker.app.util
-
-inline fun Array<*>.firstIsInstanceOrNull(): R? {
- return filterIsInstanceTo(ArrayList()).firstOrNull()
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/view/DelayFreeDelayDrawerLayout.kt b/app/src/main/java/codes/side/andcolorpicker/app/view/DelayFreeDelayDrawerLayout.kt
deleted file mode 100644
index d78a0f0..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/view/DelayFreeDelayDrawerLayout.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package codes.side.andcolorpicker.app.view
-
-import android.content.Context
-import android.util.AttributeSet
-import androidx.drawerlayout.widget.DrawerLayout
-
-class DelayFreeDelayDrawerLayout : DrawerLayout {
- constructor(context: Context) : super(context)
-
- constructor(
- context: Context,
- attrs: AttributeSet?
- ) : super(
- context,
- attrs
- )
-
- constructor(
- context: Context,
- attrs: AttributeSet?,
- defStyleAttr: Int
- ) : super(
- context,
- attrs,
- defStyleAttr
- )
-
- override fun shouldDelayChildPressedState(): Boolean {
- return false
- }
-}
diff --git a/app/src/main/java/codes/side/andcolorpicker/app/view/DelayFreeScrollView.kt b/app/src/main/java/codes/side/andcolorpicker/app/view/DelayFreeScrollView.kt
deleted file mode 100644
index 0683732..0000000
--- a/app/src/main/java/codes/side/andcolorpicker/app/view/DelayFreeScrollView.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package codes.side.andcolorpicker.app.view
-
-import android.content.Context
-import android.util.AttributeSet
-import android.widget.ScrollView
-
-class DelayFreeScrollView : ScrollView {
- constructor(context: Context) : super(context)
-
- constructor(
- context: Context,
- attrs: AttributeSet?
- ) : super(
- context,
- attrs
- )
-
- constructor(
- context: Context,
- attrs: AttributeSet?,
- defStyleAttr: Int
- ) : super(
- context,
- attrs,
- defStyleAttr
- )
-
- override fun shouldDelayChildPressedState(): Boolean {
- return false
- }
-}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index dbbecf0..0000000
--- a/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_standalone.xml b/app/src/main/res/layout/activity_standalone.xml
deleted file mode 100644
index 7d98659..0000000
--- a/app/src/main/res/layout/activity_standalone.xml
+++ /dev/null
@@ -1,83 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_cmyk_seek_bar.xml b/app/src/main/res/layout/fragment_cmyk_seek_bar.xml
deleted file mode 100644
index 345d894..0000000
--- a/app/src/main/res/layout/fragment_cmyk_seek_bar.xml
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_cmyk_seek_bar_github_sample.xml b/app/src/main/res/layout/fragment_cmyk_seek_bar_github_sample.xml
deleted file mode 100644
index b00ae8a..0000000
--- a/app/src/main/res/layout/fragment_cmyk_seek_bar_github_sample.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_default_seek_bar.xml b/app/src/main/res/layout/fragment_default_seek_bar.xml
deleted file mode 100644
index bbd5e3f..0000000
--- a/app/src/main/res/layout/fragment_default_seek_bar.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_hsl_seek_bar.xml b/app/src/main/res/layout/fragment_hsl_seek_bar.xml
deleted file mode 100644
index 52aa40d..0000000
--- a/app/src/main/res/layout/fragment_hsl_seek_bar.xml
+++ /dev/null
@@ -1,262 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_hsl_seek_bar_github_sample.xml b/app/src/main/res/layout/fragment_hsl_seek_bar_github_sample.xml
deleted file mode 100644
index 45c22c3..0000000
--- a/app/src/main/res/layout/fragment_hsl_seek_bar_github_sample.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_hsl_seek_bar_recyclerview.xml b/app/src/main/res/layout/fragment_hsl_seek_bar_recyclerview.xml
deleted file mode 100644
index 5177a1c..0000000
--- a/app/src/main/res/layout/fragment_hsl_seek_bar_recyclerview.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_lab_seek_bar.xml b/app/src/main/res/layout/fragment_lab_seek_bar.xml
deleted file mode 100644
index 720e0db..0000000
--- a/app/src/main/res/layout/fragment_lab_seek_bar.xml
+++ /dev/null
@@ -1,84 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_rgb_seek_bar.xml b/app/src/main/res/layout/fragment_rgb_seek_bar.xml
deleted file mode 100644
index 95f6769..0000000
--- a/app/src/main/res/layout/fragment_rgb_seek_bar.xml
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_swatch.xml b/app/src/main/res/layout/fragment_swatch.xml
deleted file mode 100644
index 79424bc..0000000
--- a/app/src/main/res/layout/fragment_swatch.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_wip.xml b/app/src/main/res/layout/fragment_wip.xml
deleted file mode 100644
index 72b1243..0000000
--- a/app/src/main/res/layout/fragment_wip.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/layout/layout_item_hsl_seek_bar.xml b/app/src/main/res/layout/layout_item_hsl_seek_bar.xml
deleted file mode 100644
index 170c955..0000000
--- a/app/src/main/res/layout/layout_item_hsl_seek_bar.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index 016bcb8..0000000
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
deleted file mode 100644
index 016bcb8..0000000
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index 8c697ac..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
deleted file mode 100644
index 59b5b70..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
deleted file mode 100644
index 5aa2d81..0000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index 09d9cc2..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
deleted file mode 100644
index 9368c0c..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
deleted file mode 100644
index cbf5c75..0000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index de161c2..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 3b80b82..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
deleted file mode 100644
index 95ba016..0000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 3d6362b..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 2378142..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
deleted file mode 100644
index faef069..0000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index 6346ad9..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 3a43be2..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
deleted file mode 100644
index f9b8d88..0000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
deleted file mode 100644
index 3743944..0000000
--- a/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
- #9E9E9E
- #707070
- #9E9E9E
- #DCDCDC
-
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
deleted file mode 100644
index 53600af..0000000
--- a/app/src/main/res/values/dimens.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- 0dp
- 4dp
- 8dp
- 12dp
- 16dp
-
- 2dp
-
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
deleted file mode 100644
index 99cea76..0000000
--- a/app/src/main/res/values/ic_launcher_background.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- #FFFFFF
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
deleted file mode 100644
index 8d1f01f..0000000
--- a/app/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
- andColorPicker Sample
- Color picking made easy
- Dynamic
- Dynamic color component
- Coloring mode
- Pure
- Output
- Plain
- Randomize
- Work In Progress :D
- Show dialog
- L
- A
- B
-
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
deleted file mode 100644
index 70d8978..0000000
--- a/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/test/java/codes/side/andcolorpicker/app/ExampleUnitTest.kt b/app/src/test/java/codes/side/andcolorpicker/app/ExampleUnitTest.kt
deleted file mode 100644
index 109cba4..0000000
--- a/app/src/test/java/codes/side/andcolorpicker/app/ExampleUnitTest.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package codes.side.andcolorpicker.app
-
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(
- 4,
- 2 + 2
- )
- }
-}
diff --git a/build.gradle b/build.gradle
deleted file mode 100644
index c4b3c89..0000000
--- a/build.gradle
+++ /dev/null
@@ -1,23 +0,0 @@
-buildscript {
- ext.kotlin_version = "1.4.32"
- repositories {
- google()
- mavenCentral()
- }
- dependencies {
- classpath "com.android.tools.build:gradle:4.1.3"
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
- classpath "com.google.android.gms:oss-licenses-plugin:0.10.4"
- }
-}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- }
-}
-
-task clean(type: Delete) {
- delete rootProject.buildDir
-}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..debd032
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,22 @@
+import kotlinx.validation.ExperimentalBCVApi
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform) apply false
+ alias(libs.plugins.composeMultiplatform) apply false
+ alias(libs.plugins.composeCompiler) apply false
+ alias(libs.plugins.androidKmpLibrary) apply false
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.binaryCompatibilityValidator)
+}
+
+apiValidation {
+ // Sample modules are not published; only the library's API surface is tracked.
+ ignoredProjects += listOf("androidApp", "shared")
+
+ @OptIn(ExperimentalBCVApi::class)
+ klib {
+ // Also track the klib ABI (iOS targets). Klib dumps only require compiling
+ // klibs, which works on all hosts (only linking needs macOS).
+ enabled = true
+ }
+}
diff --git a/colorpicker/build.gradle.kts b/colorpicker/build.gradle.kts
new file mode 100644
index 0000000..f4e818f
--- /dev/null
+++ b/colorpicker/build.gradle.kts
@@ -0,0 +1,143 @@
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+ alias(libs.plugins.androidKmpLibrary)
+ alias(libs.plugins.dokka)
+ `maven-publish`
+ signing
+}
+
+group = "codes.side"
+version = "1.0.0"
+
+kotlin {
+ explicitApi()
+
+ jvm()
+
+ android {
+ namespace = "codes.side.colorpicker"
+ compileSdk = 37
+ minSdk = 24
+
+ withHostTest {}
+
+ @OptIn(ExperimentalKotlinGradlePluginApi::class)
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+ }
+
+ // iosX64 (Intel simulator) removed: Compose Multiplatform stopped publishing
+ // iosx64 artifacts as of 1.11.0.
+ listOf(
+ iosArm64(),
+ iosSimulatorArm64(),
+ ).forEach { iosTarget ->
+ iosTarget.binaries.framework {
+ baseName = "ColorPicker"
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ // api: types from these modules appear in the library's public API
+ // (@Composable/@Immutable/@Stable from runtime; Modifier, Color, Shape,
+ // Dp from ui; ImmutableList in ColorSlider's signature). Consumers
+ // compile against them, so they must be on the consumer's compile
+ // classpath. foundation is api because every public picker composable
+ // is designed to be composed with foundation layouts and its widgets
+ // are foundation-based slot hosts.
+ api(libs.compose.runtime)
+ api(libs.compose.foundation)
+ api(libs.compose.ui)
+ api(libs.kotlinx.collections.immutable)
+ // implementation: no material3 or coroutines types leak into public
+ // signatures (material3 is an internal rendering detail).
+ implementation(libs.compose.material3)
+ implementation(libs.kotlinx.coroutines.core)
+ }
+ commonTest.dependencies {
+ implementation(libs.kotlin.test)
+ implementation(libs.kotlinx.coroutines.test)
+ }
+ androidMain.dependencies {
+ implementation(libs.kotlinx.coroutines.android)
+ }
+ }
+}
+
+// Maven Central publication. The -javadoc jar packages the Dokka HTML output so the
+// published artifact carries real API documentation.
+val javadocJar = tasks.register("javadocJar") {
+ archiveClassifier.set("javadoc")
+ val dokkaHtml = tasks.named("dokkaGeneratePublicationHtml")
+ dependsOn(dokkaHtml)
+ from(dokkaHtml)
+}
+
+publishing {
+ publications.withType {
+ artifact(javadocJar)
+
+ pom {
+ name.set("andColorPicker")
+ description.set("Multiplatform color picker library for Android & iOS")
+ url.set("https://github.com/side-codes/andColorPicker")
+ licenses {
+ license {
+ name.set("The Apache License, Version 2.0")
+ url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
+ }
+ }
+ developers {
+ developer {
+ id.set("dummyco")
+ name.set("Illia Achour")
+ email.set("ilyaachour@gmail.com")
+ }
+ developer {
+ id.set("N7k")
+ name.set("Maksim Novik")
+ email.set("nvk.mse@gmail.com")
+ }
+ }
+ scm {
+ connection.set("scm:git:github.com/side-codes/andColorPicker.git")
+ developerConnection.set("scm:git:ssh://github.com/side-codes/andColorPicker.git")
+ url.set("https://github.com/side-codes/andColorPicker/tree/master")
+ }
+ }
+ }
+
+ repositories {
+ maven {
+ name = "sonatype"
+ // OSSRH (s01.oss.sonatype.org) was decommissioned 2025-06-30. These endpoints are the
+ // Central Portal's OSSRH Staging API compatibility service; credentials must be
+ // Central Portal user tokens, and deployments are released from central.sonatype.com.
+ val releasesRepoUrl = "https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/"
+ val snapshotsRepoUrl = "https://central.sonatype.com/repository/maven-snapshots/"
+ url = uri(if (version.toString().endsWith("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl)
+ credentials {
+ username = findProperty("ossrhUsername") as String? ?: System.getenv("OSSRH_USERNAME")
+ password = findProperty("ossrhPassword") as String? ?: System.getenv("OSSRH_PASSWORD")
+ }
+ }
+ }
+}
+
+signing {
+ val signingKeyId = findProperty("signing.keyId") as String? ?: System.getenv("SIGNING_KEY_ID")
+ val signingPassword = findProperty("signing.password") as String? ?: System.getenv("SIGNING_PASSWORD")
+ val signingKey = findProperty("signing.key") as String? ?: System.getenv("SIGNING_KEY")
+ if (signingKey != null) {
+ useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword)
+ }
+ sign(publishing.publications)
+}
diff --git a/colorpicker/src/androidMain/AndroidManifest.xml b/colorpicker/src/androidMain/AndroidManifest.xml
new file mode 100644
index 0000000..8072ee0
--- /dev/null
+++ b/colorpicker/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/ArgbInt.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/ArgbInt.kt
new file mode 100644
index 0000000..9f7fe9c
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/ArgbInt.kt
@@ -0,0 +1,54 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.roundToInt
+
+/**
+ * Packs this color into an ARGB [Int] (`0xAARRGGBB`), quantizing each `0..1` channel
+ * to 8 bits by rounding to the nearest integer.
+ */
+public fun RgbColor.toArgbInt(): Int {
+ val a = (alpha * 255f).roundToInt() and 0xFF
+ val r = (red * 255f).roundToInt() and 0xFF
+ val g = (green * 255f).roundToInt() and 0xFF
+ val b = (blue * 255f).roundToInt() and 0xFF
+ return (a shl 24) or (r shl 16) or (g shl 8) or b
+}
+
+/**
+ * Unpacks this ARGB [Int] (`0xAARRGGBB`) into an [RgbColor] with `0..1` channels.
+ * Alpha is taken from the top byte; every input is valid, so this never throws.
+ */
+public fun Int.toRgbColor(): RgbColor = RgbColor(
+ red = ((this shr 16) and 0xFF) / 255f,
+ green = ((this shr 8) and 0xFF) / 255f,
+ blue = (this and 0xFF) / 255f,
+ alpha = ((this ushr 24) and 0xFF) / 255f,
+)
+
+internal fun argb(alpha: Int, red: Int, green: Int, blue: Int): Int =
+ ((alpha.coerceIn(0, 255) and 0xFF) shl 24) or
+ ((red.coerceIn(0, 255) and 0xFF) shl 16) or
+ ((green.coerceIn(0, 255) and 0xFF) shl 8) or
+ (blue.coerceIn(0, 255) and 0xFF)
+
+internal fun setAlphaComponent(color: Int, alpha: Int): Int =
+ (color and 0x00FFFFFF) or ((alpha.coerceIn(0, 255) and 0xFF) shl 24)
+
+/**
+ * Blends [color1] and [color2] using the given [ratio]: `0.0` returns [color1] and `1.0`
+ * returns [color2]. Ratios outside `0..1` are clamped to that range, and a `NaN` [ratio]
+ * is treated as `0` (returning [color1]).
+ *
+ * Note: this deviates from AndroidX `ColorUtils.blendARGB`, which truncates each blended
+ * channel; here every channel is rounded to the nearest integer for unbiased results.
+ */
+internal fun blendArgb(color1: Int, color2: Int, ratio: Float): Int {
+ val r = if (ratio.isNaN()) 0f else ratio.coerceIn(0f, 1f)
+ val inverseRatio = 1f - r
+ val a = ((color1 ushr 24) and 0xFF) * inverseRatio + ((color2 ushr 24) and 0xFF) * r
+ val red = ((color1 shr 16) and 0xFF) * inverseRatio + ((color2 shr 16) and 0xFF) * r
+ val g = ((color1 shr 8) and 0xFF) * inverseRatio + ((color2 shr 8) and 0xFF) * r
+ val b = (color1 and 0xFF) * inverseRatio + (color2 and 0xFF) * r
+ return argb(a.roundToInt(), red.roundToInt(), g.roundToInt(), b.roundToInt())
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/CmykConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/CmykConversions.kt
new file mode 100644
index 0000000..d80d66c
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/CmykConversions.kt
@@ -0,0 +1,55 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.RgbColor
+
+/** Converts this CMYK color to RGB. Alpha is carried over unchanged. */
+public fun CmykColor.toRgb(): RgbColor {
+ val r = (1f - cyan) * (1f - key)
+ val g = (1f - magenta) * (1f - key)
+ val b = (1f - yellow) * (1f - key)
+
+ return RgbColor(
+ red = r.coerceIn(0f, 1f),
+ green = g.coerceIn(0f, 1f),
+ blue = b.coerceIn(0f, 1f),
+ alpha = alpha,
+ )
+}
+
+/**
+ * Converts this RGB color to CMYK. Pure black maps to `key = 1` with zero ink in the
+ * other channels. Alpha is carried over unchanged.
+ */
+public fun RgbColor.toCmyk(): CmykColor {
+ val k = 1f - maxOf(red, green, blue)
+
+ if (k >= 1f) {
+ return CmykColor(cyan = 0f, magenta = 0f, yellow = 0f, key = 1f, alpha = alpha)
+ }
+
+ val c = (1f - red - k) / (1f - k)
+ val m = (1f - green - k) / (1f - k)
+ val y = (1f - blue - k) / (1f - k)
+
+ return CmykColor(
+ cyan = c.coerceIn(0f, 1f),
+ magenta = m.coerceIn(0f, 1f),
+ yellow = y.coerceIn(0f, 1f),
+ key = k.coerceIn(0f, 1f),
+ alpha = alpha,
+ )
+}
+
+/** Converts this CMYK color to HSL by way of RGB. Alpha is carried over unchanged. */
+public fun CmykColor.toHsl(): HslColor = toRgb().toHsl()
+
+/** Converts this HSL color to CMYK by way of RGB. Alpha is carried over unchanged. */
+public fun HslColor.toCmyk(): CmykColor = toRgb().toCmyk()
+
+/** Packs this CMYK color into an ARGB [Int] (`0xAARRGGBB`); see [RgbColor.toArgbInt]. */
+public fun CmykColor.toArgbInt(): Int = toRgb().toArgbInt()
+
+/** Unpacks this ARGB [Int] (`0xAARRGGBB`) into a [CmykColor]; see [Int.toRgbColor]. */
+public fun Int.toCmykColor(): CmykColor = toRgbColor().toCmyk()
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/ComposeColorExt.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/ComposeColorExt.kt
new file mode 100644
index 0000000..156699b
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/ComposeColorExt.kt
@@ -0,0 +1,47 @@
+package codes.side.colorpicker.conversion
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.colorspace.ColorSpaces
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.RgbColor
+
+/** Converts this color to a Compose [Color] in the sRGB color space, preserving alpha. */
+public fun RgbColor.toComposeColor(): Color =
+ Color(red = red, green = green, blue = blue, alpha = alpha)
+
+/** Converts this color to a Compose [Color] in the sRGB color space, preserving alpha. */
+public fun HslColor.toComposeColor(): Color = toRgb().toComposeColor()
+
+/** Converts this color to a Compose [Color] in the sRGB color space, preserving alpha. */
+public fun CmykColor.toComposeColor(): Color = toRgb().toComposeColor()
+
+/** Converts this color to a Compose [Color] in the sRGB color space, preserving alpha. */
+public fun LabColor.toComposeColor(): Color = toRgb().toComposeColor()
+
+/**
+ * Converts this Compose [Color] to an [RgbColor], converting to the sRGB color space
+ * first if needed (clamping any out-of-gamut channels to `0..1`).
+ *
+ * @throws IllegalArgumentException if this is [Color.Unspecified].
+ */
+public fun Color.toRgbColor(): RgbColor {
+ require(this != Color.Unspecified) { "Cannot convert Color.Unspecified to RgbColor" }
+ val srgb = convert(ColorSpaces.Srgb)
+ return RgbColor(
+ red = srgb.red.coerceIn(0f, 1f),
+ green = srgb.green.coerceIn(0f, 1f),
+ blue = srgb.blue.coerceIn(0f, 1f),
+ alpha = srgb.alpha.coerceIn(0f, 1f),
+ )
+}
+
+/** Converts this Compose [Color] to an [HslColor]; see [Color.toRgbColor] for sRGB handling. */
+public fun Color.toHslColor(): HslColor = toRgbColor().toHsl()
+
+/** Converts this Compose [Color] to a [CmykColor]; see [Color.toRgbColor] for sRGB handling. */
+public fun Color.toCmykColor(): CmykColor = toRgbColor().toCmyk()
+
+/** Converts this Compose [Color] to a [LabColor]; see [Color.toRgbColor] for sRGB handling. */
+public fun Color.toLabColor(): LabColor = toRgbColor().toLab()
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/HexConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/HexConversions.kt
new file mode 100644
index 0000000..99f1bce
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/HexConversions.kt
@@ -0,0 +1,72 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.PickerColor
+import codes.side.colorpicker.model.RgbColor
+
+/**
+ * Formats this color as an uppercase hex string with a leading `#`.
+ *
+ * The color is first packed into an ARGB [Int] (quantizing each channel to 8 bits), so the
+ * alpha channel comes first: the result is `#AARRGGBB` when [includeAlpha] is `true`
+ * (the default) and `#RRGGBB` otherwise.
+ */
+public fun PickerColor.toHexString(includeAlpha: Boolean = true): String = when (this) {
+ is RgbColor -> toArgbInt()
+ is HslColor -> toArgbInt()
+ is CmykColor -> toArgbInt()
+ is LabColor -> toArgbInt()
+}.toHexColorString(includeAlpha = includeAlpha)
+
+/**
+ * Formats this packed ARGB [Int] as an uppercase hex string with a leading `#`.
+ *
+ * The alpha channel comes first: the result is `#AARRGGBB` when [includeAlpha] is `true`
+ * (the default) and `#RRGGBB` otherwise.
+ */
+public fun Int.toHexColorString(includeAlpha: Boolean = true): String {
+ val rgb = (this and 0xFFFFFF).toString(16).uppercase().padStart(6, '0')
+ if (!includeAlpha) return "#$rgb"
+ val alpha = ((this ushr 24) and 0xFF).toString(16).uppercase().padStart(2, '0')
+ return "#$alpha$rgb"
+}
+
+/**
+ * Parses this string as a hex color, or returns `null` if it is not a valid hex color.
+ *
+ * The leading `#` is optional and parsing is case-insensitive. Supported forms:
+ * - `RGB` shorthand (3 digits, e.g. `#ABC` expands to `#AABBCC`), alpha defaults to `FF`
+ * - `RRGGBB` (6 digits), alpha defaults to `FF`
+ * - `AARRGGBB` (8 digits, alpha first)
+ *
+ * Any other length or any non-hex character yields `null`; this function never throws.
+ */
+public fun String.toRgbColorOrNull(): RgbColor? {
+ val hex = removePrefix("#")
+ if (hex.any { it !in '0'..'9' && it !in 'a'..'f' && it !in 'A'..'F' }) {
+ return null
+ }
+ val argb = when (hex.length) {
+ 3 -> buildString(8) {
+ append("FF")
+ for (char in hex) {
+ append(char)
+ append(char)
+ }
+ }
+ 6 -> "FF$hex"
+ 8 -> hex
+ else -> return null
+ }
+ return argb.toLong(16).toInt().toRgbColor()
+}
+
+/**
+ * Parses this string as a hex color (see [toRgbColorOrNull] for the accepted formats).
+ *
+ * @throws IllegalArgumentException if the string is not a valid hex color.
+ */
+public fun String.toRgbColor(): RgbColor =
+ toRgbColorOrNull() ?: throw IllegalArgumentException("Invalid hex color string: '$this'")
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/HslConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/HslConversions.kt
new file mode 100644
index 0000000..c2171bb
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/HslConversions.kt
@@ -0,0 +1,79 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.abs
+
+/** Converts this HSL color to RGB. Alpha is carried over unchanged. */
+public fun HslColor.toRgb(): RgbColor {
+ val h = hue
+ val s = saturation
+ val l = lightness
+
+ val c = (1f - abs(2f * l - 1f)) * s
+ val m = l - 0.5f * c
+ val x = c * (1f - abs((h / 60f) % 2f - 1f))
+
+ val hueSegment = (h / 60f).toInt()
+
+ val r: Float
+ val g: Float
+ val b: Float
+
+ when (hueSegment) {
+ 0 -> { r = c + m; g = x + m; b = m }
+ 1 -> { r = x + m; g = c + m; b = m }
+ 2 -> { r = m; g = c + m; b = x + m }
+ 3 -> { r = m; g = x + m; b = c + m }
+ 4 -> { r = x + m; g = m; b = c + m }
+ else -> { r = c + m; g = m; b = x + m }
+ }
+
+ return RgbColor(
+ red = r.coerceIn(0f, 1f),
+ green = g.coerceIn(0f, 1f),
+ blue = b.coerceIn(0f, 1f),
+ alpha = alpha,
+ )
+}
+
+/**
+ * Converts this RGB color to HSL. Achromatic colors (grays) map to hue 0 and
+ * saturation 0. Alpha is carried over unchanged.
+ */
+public fun RgbColor.toHsl(): HslColor {
+ val max = maxOf(red, green, blue)
+ val min = minOf(red, green, blue)
+ val delta = max - min
+
+ val l = (max + min) / 2f
+
+ val h: Float
+ val s: Float
+
+ if (max == min) {
+ h = 0f
+ s = 0f
+ } else {
+ h = when (max) {
+ red -> ((green - blue) / delta) % 6f
+ green -> ((blue - red) / delta) + 2f
+ else -> ((red - green) / delta) + 4f
+ } * 60f
+
+ s = delta / (1f - abs(2f * l - 1f))
+ }
+
+ return HslColor(
+ hue = ((h % 360f + 360f) % 360f).coerceIn(0f, 360f),
+ saturation = s.coerceIn(0f, 1f),
+ lightness = l.coerceIn(0f, 1f),
+ alpha = alpha,
+ )
+}
+
+/** Packs this HSL color into an ARGB [Int] (`0xAARRGGBB`); see [RgbColor.toArgbInt]. */
+public fun HslColor.toArgbInt(): Int = toRgb().toArgbInt()
+
+/** Unpacks this ARGB [Int] (`0xAARRGGBB`) into an [HslColor]; see [Int.toRgbColor]. */
+public fun Int.toHslColor(): HslColor = toRgbColor().toHsl()
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/LabConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/LabConversions.kt
new file mode 100644
index 0000000..8ee0ce6
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/LabConversions.kt
@@ -0,0 +1,99 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.cbrt
+import kotlin.math.pow
+
+// D65 reference white matching the sRGB conversion matrices below.
+private const val XN = 0.95047
+private const val YN = 1.0
+private const val ZN = 1.08883
+
+// CIE standard constants: EPSILON = (6/29)^3, KAPPA = (29/3)^3.
+private const val EPSILON = 216.0 / 24389.0
+private const val KAPPA = 24389.0 / 27.0
+
+/**
+ * Converts this CIELAB color (D65 reference white) to sRGB. Out-of-gamut results are
+ * clamped channel-wise into `0..1`. Alpha is carried over unchanged.
+ */
+public fun LabColor.toRgb(): RgbColor {
+ val lD = l.toDouble()
+ val aD = a.toDouble()
+ val bD = b.toDouble()
+
+ val fy = (lD + 16.0) / 116.0
+ val fx = aD / 500.0 + fy
+ val fz = fy - bD / 200.0
+
+ val fx3 = fx.pow(3)
+ val fz3 = fz.pow(3)
+
+ val xr = if (fx3 > EPSILON) fx3 else (116.0 * fx - 16.0) / KAPPA
+ val yr = if (lD > 8.0) fy.pow(3) else lD / KAPPA
+ val zr = if (fz3 > EPSILON) fz3 else (116.0 * fz - 16.0) / KAPPA
+
+ val x = xr * XN
+ val y = yr * YN
+ val z = zr * ZN
+
+ val rLinear = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
+ val gLinear = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
+ val bLinear = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
+
+ return RgbColor(
+ red = delinearize(rLinear).toFloat().coerceIn(0f, 1f),
+ green = delinearize(gLinear).toFloat().coerceIn(0f, 1f),
+ blue = delinearize(bLinear).toFloat().coerceIn(0f, 1f),
+ alpha = alpha,
+ )
+}
+
+/** Converts this sRGB color to CIELAB (D65 reference white). Alpha is carried over unchanged. */
+public fun RgbColor.toLab(): LabColor {
+ val rLinear = linearize(red.toDouble())
+ val gLinear = linearize(green.toDouble())
+ val bLinear = linearize(blue.toDouble())
+
+ val x = 0.4124564 * rLinear + 0.3575761 * gLinear + 0.1804375 * bLinear
+ val y = 0.2126729 * rLinear + 0.7151522 * gLinear + 0.0721750 * bLinear
+ val z = 0.0193339 * rLinear + 0.1191920 * gLinear + 0.9503041 * bLinear
+
+ val fx = labF(x / XN)
+ val fy = labF(y / YN)
+ val fz = labF(z / ZN)
+
+ val l = (116.0 * fy - 16.0).toFloat()
+ val a = (500.0 * (fx - fy)).toFloat()
+ val b = (200.0 * (fy - fz)).toFloat()
+
+ return LabColor(
+ l = l.coerceIn(0f, 100f),
+ a = a.coerceIn(-128f, 127f),
+ b = b.coerceIn(-128f, 127f),
+ alpha = alpha,
+ )
+}
+
+private fun labF(t: Double): Double =
+ if (t > EPSILON) cbrt(t) else (KAPPA * t + 16.0) / 116.0
+
+internal fun linearize(c: Double): Double =
+ if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
+
+private fun delinearize(c: Double): Double =
+ if (c <= 0.0031308) c * 12.92 else 1.055 * c.pow(1.0 / 2.4) - 0.055
+
+/** Converts this CIELAB color to HSL by way of RGB. Alpha is carried over unchanged. */
+public fun LabColor.toHsl(): HslColor = toRgb().toHsl()
+
+/** Converts this HSL color to CIELAB by way of RGB. Alpha is carried over unchanged. */
+public fun HslColor.toLab(): LabColor = toRgb().toLab()
+
+/** Packs this CIELAB color into an ARGB [Int] (`0xAARRGGBB`); see [RgbColor.toArgbInt]. */
+public fun LabColor.toArgbInt(): Int = toRgb().toArgbInt()
+
+/** Unpacks this ARGB [Int] (`0xAARRGGBB`) into a [LabColor]; see [Int.toRgbColor]. */
+public fun Int.toLabColor(): LabColor = toRgbColor().toLab()
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/RgbConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/RgbConversions.kt
new file mode 100644
index 0000000..5d23c1c
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/RgbConversions.kt
@@ -0,0 +1,18 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.RgbColor
+
+/**
+ * Returns [RgbColor.Black] or [RgbColor.White], whichever contrasts better with this
+ * color. Uses WCAG relative luminance with a threshold of 0.179 — the luminance at
+ * which black and white text yield equal WCAG contrast ratios — rather than the
+ * simpler 0.5 midpoint, which would pick black on backgrounds too dark for it.
+ * Alpha is ignored.
+ */
+internal fun RgbColor.contrastColor(): RgbColor {
+ // WCAG relative luminance on linearized sRGB channels.
+ val luminance = 0.2126 * linearize(red.toDouble()) +
+ 0.7152 * linearize(green.toDouble()) +
+ 0.0722 * linearize(blue.toDouble())
+ return if (luminance > 0.179) RgbColor.Black else RgbColor.White
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/CmykColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/CmykColor.kt
new file mode 100644
index 0000000..b5a4159
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/CmykColor.kt
@@ -0,0 +1,107 @@
+package codes.side.colorpicker.model
+
+import androidx.compose.runtime.Immutable
+import kotlin.math.roundToInt
+
+/**
+ * An immutable color in the CMYK (cyan, magenta, yellow, key/black) color space.
+ *
+ * All channels — [cyan], [magenta], [yellow], [key], and [alpha] — are in `0..1`.
+ * The constructor throws [IllegalArgumentException] for out-of-range or NaN values;
+ * use [fromInt] for a clamping alternative.
+ *
+ * The no-argument constructor `CmykColor()` is opaque white (zero ink coverage).
+ * Note that each model's default is intentionally its space's most natural origin,
+ * so defaults differ per model: `RgbColor()` = black, `HslColor()` = red,
+ * `CmykColor()` = white, `LabColor()` = mid-gray.
+ */
+@Immutable
+public class CmykColor(
+ cyan: Float = 0f,
+ magenta: Float = 0f,
+ yellow: Float = 0f,
+ key: Float = 0f,
+ alpha: Float = 1f,
+) : PickerColor {
+
+ init {
+ require(cyan in 0f..1f) { "Cyan must be in 0..1, was $cyan" }
+ require(magenta in 0f..1f) { "Magenta must be in 0..1, was $magenta" }
+ require(yellow in 0f..1f) { "Yellow must be in 0..1, was $yellow" }
+ require(key in 0f..1f) { "Key must be in 0..1, was $key" }
+ require(alpha in 0f..1f) { "Alpha must be in 0..1, was $alpha" }
+ }
+
+ // "+ 0f" normalizes -0.0f to 0.0f so equality can't split on signed zero.
+ /** Cyan channel in `0..1`. */
+ public val cyan: Float = cyan + 0f
+ /** Magenta channel in `0..1`. */
+ public val magenta: Float = magenta + 0f
+ /** Yellow channel in `0..1`. */
+ public val yellow: Float = yellow + 0f
+ /** Key (black) channel in `0..1`. */
+ public val key: Float = key + 0f
+ override val alpha: Float = alpha + 0f
+
+ /** [cyan] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intCyan: Int get() = (cyan * 100f).roundToInt()
+ /** [magenta] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intMagenta: Int get() = (magenta * 100f).roundToInt()
+ /** [yellow] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intYellow: Int get() = (yellow * 100f).roundToInt()
+ /** [key] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intKey: Int get() = (key * 100f).roundToInt()
+ /** [alpha] scaled to `0..255` and rounded to the nearest integer. */
+ public val intAlpha: Int get() = (alpha * 255f).roundToInt()
+
+ /** Returns a copy of this color, replacing only the channels passed explicitly. */
+ public fun copy(
+ cyan: Float = this.cyan,
+ magenta: Float = this.magenta,
+ yellow: Float = this.yellow,
+ key: Float = this.key,
+ alpha: Float = this.alpha,
+ ): CmykColor = CmykColor(cyan = cyan, magenta = magenta, yellow = yellow, key = key, alpha = alpha)
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is CmykColor) return false
+ return cyan == other.cyan &&
+ magenta == other.magenta &&
+ yellow == other.yellow &&
+ key == other.key &&
+ alpha == other.alpha
+ }
+
+ override fun hashCode(): Int {
+ var result = cyan.hashCode()
+ result = 31 * result + magenta.hashCode()
+ result = 31 * result + yellow.hashCode()
+ result = 31 * result + key.hashCode()
+ result = 31 * result + alpha.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "CmykColor(cyan=$cyan, magenta=$magenta, yellow=$yellow, key=$key, alpha=$alpha)"
+
+ public companion object {
+ /** Opaque black. */
+ public val Black: CmykColor = CmykColor(0f, 0f, 0f, 1f)
+ /** Opaque white. */
+ public val White: CmykColor = CmykColor(0f, 0f, 0f, 0f)
+
+ /**
+ * Creates a [CmykColor] from integer channels: [cyan], [magenta], [yellow], and
+ * [key] in `0..100` percent, [alpha] in `0..255`. Unlike the constructor,
+ * out-of-range values are clamped instead of throwing.
+ */
+ public fun fromInt(cyan: Int, magenta: Int, yellow: Int, key: Int, alpha: Int = 255): CmykColor = CmykColor(
+ cyan = (cyan / 100f).coerceIn(0f, 1f),
+ magenta = (magenta / 100f).coerceIn(0f, 1f),
+ yellow = (yellow / 100f).coerceIn(0f, 1f),
+ key = (key / 100f).coerceIn(0f, 1f),
+ alpha = (alpha / 255f).coerceIn(0f, 1f),
+ )
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/HslColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/HslColor.kt
new file mode 100644
index 0000000..6cf5439
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/HslColor.kt
@@ -0,0 +1,101 @@
+package codes.side.colorpicker.model
+
+import androidx.compose.runtime.Immutable
+import kotlin.math.roundToInt
+
+/**
+ * An immutable color in the HSL (hue, saturation, lightness) color space.
+ *
+ * [hue] is in degrees; the constructor accepts `0..360` but `360` is normalized to `0`,
+ * so the stored value is always in `0..360` (exclusive). [saturation], [lightness], and
+ * [alpha] are in `0..1`. The constructor throws [IllegalArgumentException] for
+ * out-of-range or NaN values; use [fromInt] for a clamping alternative.
+ *
+ * The no-argument constructor `HslColor()` is opaque pure red (hue 0, full saturation,
+ * mid lightness). Note that each model's default is intentionally its space's most
+ * natural origin, so defaults differ per model: `RgbColor()` = black,
+ * `HslColor()` = red, `CmykColor()` = white, `LabColor()` = mid-gray.
+ */
+@Immutable
+public class HslColor(
+ hue: Float = 0f,
+ saturation: Float = 1f,
+ lightness: Float = 0.5f,
+ alpha: Float = 1f,
+) : PickerColor {
+
+ init {
+ require(hue in 0f..360f) { "Hue must be in 0..360, was $hue" }
+ require(saturation in 0f..1f) { "Saturation must be in 0..1, was $saturation" }
+ require(lightness in 0f..1f) { "Lightness must be in 0..1, was $lightness" }
+ require(alpha in 0f..1f) { "Alpha must be in 0..1, was $alpha" }
+ }
+
+ // 360f is accepted for compatibility but stored as the equivalent 0f;
+ // "+ 0f" normalizes -0.0f to 0.0f so equality can't split on signed zero.
+ /** Hue in degrees, in `0..360` (exclusive) after normalization. */
+ public val hue: Float = if (hue == 360f) 0f else hue + 0f
+ /** Saturation in `0..1`. */
+ public val saturation: Float = saturation + 0f
+ /** Lightness in `0..1`. */
+ public val lightness: Float = lightness + 0f
+ override val alpha: Float = alpha + 0f
+
+ /** [hue] in degrees, rounded to the nearest integer. */
+ public val intHue: Int get() = hue.roundToInt()
+ /** [saturation] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intSaturation: Int get() = (saturation * 100f).roundToInt()
+ /** [lightness] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intLightness: Int get() = (lightness * 100f).roundToInt()
+ /** [alpha] scaled to `0..255` and rounded to the nearest integer. */
+ public val intAlpha: Int get() = (alpha * 255f).roundToInt()
+
+ /** Returns a copy of this color, replacing only the channels passed explicitly. */
+ public fun copy(
+ hue: Float = this.hue,
+ saturation: Float = this.saturation,
+ lightness: Float = this.lightness,
+ alpha: Float = this.alpha,
+ ): HslColor = HslColor(hue = hue, saturation = saturation, lightness = lightness, alpha = alpha)
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is HslColor) return false
+ return hue == other.hue &&
+ saturation == other.saturation &&
+ lightness == other.lightness &&
+ alpha == other.alpha
+ }
+
+ override fun hashCode(): Int {
+ var result = hue.hashCode()
+ result = 31 * result + saturation.hashCode()
+ result = 31 * result + lightness.hashCode()
+ result = 31 * result + alpha.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "HslColor(hue=$hue, saturation=$saturation, lightness=$lightness, alpha=$alpha)"
+
+ public companion object {
+ /** Opaque black. */
+ public val Black: HslColor = HslColor(hue = 0f, saturation = 0f, lightness = 0f)
+ /** Opaque white. */
+ public val White: HslColor = HslColor(hue = 0f, saturation = 0f, lightness = 1f)
+ /** Opaque pure red. */
+ public val Red: HslColor = HslColor(hue = 0f, saturation = 1f, lightness = 0.5f)
+
+ /**
+ * Creates an [HslColor] from integer channels: [hue] in `0..360` degrees,
+ * [saturation] and [lightness] in `0..100` percent, [alpha] in `0..255`.
+ * Unlike the constructor, out-of-range values are clamped instead of throwing.
+ */
+ public fun fromInt(hue: Int, saturation: Int, lightness: Int, alpha: Int = 255): HslColor = HslColor(
+ hue = hue.toFloat().coerceIn(0f, 360f),
+ saturation = (saturation / 100f).coerceIn(0f, 1f),
+ lightness = (lightness / 100f).coerceIn(0f, 1f),
+ alpha = (alpha / 255f).coerceIn(0f, 1f),
+ )
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/LabColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/LabColor.kt
new file mode 100644
index 0000000..3e48545
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/LabColor.kt
@@ -0,0 +1,97 @@
+package codes.side.colorpicker.model
+
+import androidx.compose.runtime.Immutable
+import kotlin.math.roundToInt
+
+/**
+ * An immutable color in the CIELAB (L*a*b*) color space, D65 reference white.
+ *
+ * [l] (lightness) is in `0..100`; [a] (green-red axis) and [b] (blue-yellow axis) are
+ * in `-128..127`; [alpha] is in `0..1`. The constructor throws [IllegalArgumentException]
+ * for out-of-range or NaN values; use [fromInt] for a clamping alternative.
+ *
+ * The no-argument constructor `LabColor()` is opaque mid-gray (L = 50 on the neutral
+ * axis). Note that each model's default is intentionally its space's most natural
+ * origin, so defaults differ per model: `RgbColor()` = black, `HslColor()` = red,
+ * `CmykColor()` = white, `LabColor()` = mid-gray.
+ */
+@Immutable
+public class LabColor(
+ l: Float = 50f,
+ a: Float = 0f,
+ b: Float = 0f,
+ alpha: Float = 1f,
+) : PickerColor {
+
+ init {
+ require(l in 0f..100f) { "L must be in 0..100, was $l" }
+ require(a in -128f..127f) { "A must be in -128..127, was $a" }
+ require(b in -128f..127f) { "B must be in -128..127, was $b" }
+ require(alpha in 0f..1f) { "Alpha must be in 0..1, was $alpha" }
+ }
+
+ // "+ 0f" normalizes -0.0f to 0.0f so equality can't split on signed zero.
+ /** Lightness (L*) in `0..100`. */
+ public val l: Float = l + 0f
+ /** Green-red axis (a*) in `-128..127`; negative is green, positive is red. */
+ public val a: Float = a + 0f
+ /** Blue-yellow axis (b*) in `-128..127`; negative is blue, positive is yellow. */
+ public val b: Float = b + 0f
+ override val alpha: Float = alpha + 0f
+
+ /** [l] rounded to the nearest integer. */
+ public val intL: Int get() = l.roundToInt()
+ /** [a] rounded to the nearest integer. */
+ public val intA: Int get() = a.roundToInt()
+ /** [b] rounded to the nearest integer. */
+ public val intB: Int get() = b.roundToInt()
+ /** [alpha] scaled to `0..255` and rounded to the nearest integer. */
+ public val intAlpha: Int get() = (alpha * 255f).roundToInt()
+
+ /** Returns a copy of this color, replacing only the channels passed explicitly. */
+ public fun copy(
+ l: Float = this.l,
+ a: Float = this.a,
+ b: Float = this.b,
+ alpha: Float = this.alpha,
+ ): LabColor = LabColor(l = l, a = a, b = b, alpha = alpha)
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is LabColor) return false
+ return l == other.l &&
+ a == other.a &&
+ b == other.b &&
+ alpha == other.alpha
+ }
+
+ override fun hashCode(): Int {
+ var result = l.hashCode()
+ result = 31 * result + a.hashCode()
+ result = 31 * result + b.hashCode()
+ result = 31 * result + alpha.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "LabColor(l=$l, a=$a, b=$b, alpha=$alpha)"
+
+ public companion object {
+ /** Opaque black. */
+ public val Black: LabColor = LabColor(l = 0f, a = 0f, b = 0f)
+ /** Opaque white. */
+ public val White: LabColor = LabColor(l = 100f, a = 0f, b = 0f)
+
+ /**
+ * Creates a [LabColor] from integer channels: [l] in `0..100`, [a] and [b] in
+ * `-128..127`, [alpha] in `0..255`. Unlike the constructor, out-of-range values
+ * are clamped instead of throwing.
+ */
+ public fun fromInt(l: Int, a: Int, b: Int, alpha: Int = 255): LabColor = LabColor(
+ l = l.toFloat().coerceIn(0f, 100f),
+ a = a.toFloat().coerceIn(-128f, 127f),
+ b = b.toFloat().coerceIn(-128f, 127f),
+ alpha = (alpha / 255f).coerceIn(0f, 1f),
+ )
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/PickerColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/PickerColor.kt
new file mode 100644
index 0000000..f4859a3
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/PickerColor.kt
@@ -0,0 +1,16 @@
+package codes.side.colorpicker.model
+
+import androidx.compose.runtime.Immutable
+
+/**
+ * An immutable color in one of the supported color spaces: [RgbColor], [HslColor],
+ * [CmykColor], or [LabColor].
+ *
+ * All implementations validate their channels on construction (the constructor throws
+ * [IllegalArgumentException] for out-of-range or NaN values) and share an [alpha] channel.
+ */
+@Immutable
+public sealed interface PickerColor {
+ /** Opacity in `0..1`, where `0` is fully transparent and `1` is fully opaque. */
+ public val alpha: Float
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/RgbColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/RgbColor.kt
new file mode 100644
index 0000000..ead3995
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/RgbColor.kt
@@ -0,0 +1,102 @@
+package codes.side.colorpicker.model
+
+import androidx.compose.runtime.Immutable
+import kotlin.math.roundToInt
+
+/**
+ * An immutable color in the sRGB color space.
+ *
+ * All channels — [red], [green], [blue], and [alpha] — are in `0..1`. The constructor
+ * throws [IllegalArgumentException] for out-of-range or NaN values; use [fromInt] for
+ * a clamping alternative that takes `0..255` integer channels.
+ *
+ * The no-argument constructor `RgbColor()` is opaque black. Note that each model's
+ * default is intentionally its space's most natural origin, so defaults differ per
+ * model: `RgbColor()` = black, `HslColor()` = red, `CmykColor()` = white,
+ * `LabColor()` = mid-gray.
+ */
+@Immutable
+public class RgbColor(
+ red: Float = 0f,
+ green: Float = 0f,
+ blue: Float = 0f,
+ alpha: Float = 1f,
+) : PickerColor {
+
+ init {
+ require(red in 0f..1f) { "Red must be in 0..1, was $red" }
+ require(green in 0f..1f) { "Green must be in 0..1, was $green" }
+ require(blue in 0f..1f) { "Blue must be in 0..1, was $blue" }
+ require(alpha in 0f..1f) { "Alpha must be in 0..1, was $alpha" }
+ }
+
+ // "+ 0f" normalizes -0.0f to 0.0f so equality can't split on signed zero.
+ /** Red channel in `0..1`. */
+ public val red: Float = red + 0f
+ /** Green channel in `0..1`. */
+ public val green: Float = green + 0f
+ /** Blue channel in `0..1`. */
+ public val blue: Float = blue + 0f
+ override val alpha: Float = alpha + 0f
+
+ /** [red] scaled to `0..255` and rounded to the nearest integer. */
+ public val intRed: Int get() = (red * 255f).roundToInt()
+ /** [green] scaled to `0..255` and rounded to the nearest integer. */
+ public val intGreen: Int get() = (green * 255f).roundToInt()
+ /** [blue] scaled to `0..255` and rounded to the nearest integer. */
+ public val intBlue: Int get() = (blue * 255f).roundToInt()
+ /** [alpha] scaled to `0..255` and rounded to the nearest integer. */
+ public val intAlpha: Int get() = (alpha * 255f).roundToInt()
+
+ /** Returns a copy of this color, replacing only the channels passed explicitly. */
+ public fun copy(
+ red: Float = this.red,
+ green: Float = this.green,
+ blue: Float = this.blue,
+ alpha: Float = this.alpha,
+ ): RgbColor = RgbColor(red = red, green = green, blue = blue, alpha = alpha)
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is RgbColor) return false
+ return red == other.red &&
+ green == other.green &&
+ blue == other.blue &&
+ alpha == other.alpha
+ }
+
+ override fun hashCode(): Int {
+ var result = red.hashCode()
+ result = 31 * result + green.hashCode()
+ result = 31 * result + blue.hashCode()
+ result = 31 * result + alpha.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "RgbColor(red=$red, green=$green, blue=$blue, alpha=$alpha)"
+
+ public companion object {
+ /** Opaque black. */
+ public val Black: RgbColor = RgbColor(0f, 0f, 0f)
+ /** Opaque white. */
+ public val White: RgbColor = RgbColor(1f, 1f, 1f)
+ /** Opaque pure red. */
+ public val Red: RgbColor = RgbColor(1f, 0f, 0f)
+ /** Opaque pure green. */
+ public val Green: RgbColor = RgbColor(0f, 1f, 0f)
+ /** Opaque pure blue. */
+ public val Blue: RgbColor = RgbColor(0f, 0f, 1f)
+
+ /**
+ * Creates an [RgbColor] from `0..255` integer channels. Unlike the constructor,
+ * out-of-range values are clamped instead of throwing.
+ */
+ public fun fromInt(red: Int, green: Int, blue: Int, alpha: Int = 255): RgbColor = RgbColor(
+ red = (red / 255f).coerceIn(0f, 1f),
+ green = (green / 255f).coerceIn(0f, 1f),
+ blue = (blue / 255f).coerceIn(0f, 1f),
+ alpha = (alpha / 255f).coerceIn(0f, 1f),
+ )
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt
new file mode 100644
index 0000000..1ffe90d
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt
@@ -0,0 +1,247 @@
+package codes.side.colorpicker.state
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import codes.side.colorpicker.conversion.toArgbInt
+import codes.side.colorpicker.conversion.toCmyk
+import codes.side.colorpicker.conversion.toHsl
+import codes.side.colorpicker.conversion.toLab
+import codes.side.colorpicker.conversion.toRgb
+import codes.side.colorpicker.conversion.toRgbColor
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.PickerColor
+import codes.side.colorpicker.model.RgbColor
+
+/**
+ * Single source of truth for the current color.
+ *
+ * Holds one authoritative [PickerColor] — whichever space was last written to.
+ * All other spaces are derived from it via [derivedStateOf] on demand (pure
+ * conversions, no writes on read). This guarantees zero round-trip drift *within
+ * the authoritative space*: repeatedly reading and re-writing channels of the
+ * space last written to never re-derives the value from a conversion. Values
+ * observed in the other spaces are conversions and carry ordinary conversion
+ * rounding.
+ *
+ * Backed by Compose snapshot state: reads are safe from any thread, but writes
+ * (the `update*` methods and [isInteracting]) are expected on the main thread,
+ * like other Compose UI state. Updates are idempotent — writing a value equal to
+ * the current one produces no observable state change.
+ *
+ * Create instances in composition via [rememberColorPickerState] or
+ * [rememberSaveableColorPickerState], or hold one directly (e.g. in a view model)
+ * using this constructor.
+ *
+ * @param initialColor the starting color; it becomes the initial authoritative
+ * value, so its runtime type also selects the initial origin space.
+ */
+@Stable
+public class ColorPickerState(initialColor: PickerColor = HslColor()) {
+
+ // The single authoritative value. Its runtime type is the origin space —
+ // whichever space was last written to.
+ private var authoritative by mutableStateOf(initialColor)
+
+ /**
+ * True while the user is actively dragging one of the library's sliders (set on the
+ * first value change, cleared when the gesture finishes or the interacting slider
+ * leaves composition mid-drag). Useful for deferring expensive work until the
+ * interaction ends. Programmatic `update*` calls do not affect this flag.
+ */
+ public var isInteracting: Boolean by mutableStateOf(false)
+ internal set
+
+ // ---- Derived spaces (pure computation, no writes on read) ----
+
+ private val hslDerived = derivedStateOf {
+ when (val c = authoritative) {
+ is HslColor -> c
+ is RgbColor -> c.toHsl().copy(alpha = c.alpha)
+ is CmykColor -> c.toRgb().toHsl().copy(alpha = c.alpha)
+ is LabColor -> c.toRgb().toHsl().copy(alpha = c.alpha)
+ }
+ }
+
+ private val rgbDerived = derivedStateOf {
+ when (val c = authoritative) {
+ is HslColor -> c.toRgb()
+ is RgbColor -> c
+ is CmykColor -> c.toRgb()
+ is LabColor -> c.toRgb()
+ }
+ }
+
+ private val cmykDerived = derivedStateOf {
+ when (val c = authoritative) {
+ is HslColor -> c.toRgb().toCmyk()
+ is RgbColor -> c.toCmyk()
+ is CmykColor -> c
+ is LabColor -> c.toRgb().toCmyk()
+ }
+ }
+
+ private val labDerived = derivedStateOf {
+ when (val c = authoritative) {
+ is HslColor -> c.toRgb().toLab()
+ is RgbColor -> c.toLab()
+ is CmykColor -> c.toRgb().toLab()
+ is LabColor -> c
+ }
+ }
+
+ // ---- Public read access ----
+
+ /** The current color as HSL; a derived conversion unless HSL is the origin space. */
+ public val hslColor: HslColor get() = hslDerived.value
+
+ /** The current color as RGB; a derived conversion unless RGB is the origin space. */
+ public val rgbColor: RgbColor get() = rgbDerived.value
+
+ /** The current color as CMYK; a derived conversion unless CMYK is the origin space. */
+ public val cmykColor: CmykColor get() = cmykDerived.value
+
+ /** The current color as CIELAB; a derived conversion unless LAB is the origin space. */
+ public val labColor: LabColor get() = labDerived.value
+
+ /** The current color as a packed ARGB [Int] (`0xAARRGGBB`). */
+ public val argbInt: Int get() = rgbColor.toArgbInt()
+
+ /** The authoritative color in whichever space was last written to. */
+ public val pickerColor: PickerColor get() = authoritative
+
+ // ---- HSL updates ----
+
+ /**
+ * Updates the hue channel. NaN is ignored; values are clamped to 0..360, and 360
+ * is stored as the equivalent 0 (see [HslColor]), so the observable range is
+ * 0..360 (exclusive).
+ */
+ public fun updateHue(hue: Float) {
+ if (hue.isNaN()) return
+ authoritative = hslColor.copy(hue = hue.coerceIn(0f, 360f))
+ }
+
+ /** Updates the saturation channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateSaturation(saturation: Float) {
+ if (saturation.isNaN()) return
+ authoritative = hslColor.copy(saturation = saturation.coerceIn(0f, 1f))
+ }
+
+ /** Updates the lightness channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateLightness(lightness: Float) {
+ if (lightness.isNaN()) return
+ authoritative = hslColor.copy(lightness = lightness.coerceIn(0f, 1f))
+ }
+
+ /** Sets [hsl] as the authoritative color; HSL becomes the origin space. */
+ public fun updateFromHsl(hsl: HslColor) {
+ authoritative = hsl
+ }
+
+ // ---- RGB updates ----
+
+ /** Updates the red channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateRed(red: Float) {
+ if (red.isNaN()) return
+ authoritative = rgbColor.copy(red = red.coerceIn(0f, 1f))
+ }
+
+ /** Updates the green channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateGreen(green: Float) {
+ if (green.isNaN()) return
+ authoritative = rgbColor.copy(green = green.coerceIn(0f, 1f))
+ }
+
+ /** Updates the blue channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateBlue(blue: Float) {
+ if (blue.isNaN()) return
+ authoritative = rgbColor.copy(blue = blue.coerceIn(0f, 1f))
+ }
+
+ /** Sets [rgb] as the authoritative color; RGB becomes the origin space. */
+ public fun updateFromRgb(rgb: RgbColor) {
+ authoritative = rgb
+ }
+
+ // ---- CMYK updates ----
+
+ /** Updates the cyan channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateCyan(cyan: Float) {
+ if (cyan.isNaN()) return
+ authoritative = cmykColor.copy(cyan = cyan.coerceIn(0f, 1f))
+ }
+
+ /** Updates the magenta channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateMagenta(magenta: Float) {
+ if (magenta.isNaN()) return
+ authoritative = cmykColor.copy(magenta = magenta.coerceIn(0f, 1f))
+ }
+
+ /** Updates the yellow channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateYellow(yellow: Float) {
+ if (yellow.isNaN()) return
+ authoritative = cmykColor.copy(yellow = yellow.coerceIn(0f, 1f))
+ }
+
+ /** Updates the key (black) channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateKey(key: Float) {
+ if (key.isNaN()) return
+ authoritative = cmykColor.copy(key = key.coerceIn(0f, 1f))
+ }
+
+ /** Sets [cmyk] as the authoritative color; CMYK becomes the origin space. */
+ public fun updateFromCmyk(cmyk: CmykColor) {
+ authoritative = cmyk
+ }
+
+ // ---- LAB updates ----
+
+ /** Updates the L (lightness) channel. NaN is ignored; values are clamped to 0..100. */
+ public fun updateLabLightness(l: Float) {
+ if (l.isNaN()) return
+ authoritative = labColor.copy(l = l.coerceIn(0f, 100f))
+ }
+
+ /** Updates the a axis. NaN is ignored; values are clamped to -128..127. */
+ public fun updateLabA(a: Float) {
+ if (a.isNaN()) return
+ authoritative = labColor.copy(a = a.coerceIn(-128f, 127f))
+ }
+
+ /** Updates the b axis. NaN is ignored; values are clamped to -128..127. */
+ public fun updateLabB(b: Float) {
+ if (b.isNaN()) return
+ authoritative = labColor.copy(b = b.coerceIn(-128f, 127f))
+ }
+
+ /** Sets [lab] as the authoritative color; LAB becomes the origin space. */
+ public fun updateFromLab(lab: LabColor) {
+ authoritative = lab
+ }
+
+ // ---- Alpha update (origin space unchanged) ----
+
+ /** Updates the alpha channel of the authoritative color. NaN is ignored; values are clamped to 0..1. */
+ public fun updateAlpha(alpha: Float) {
+ if (alpha.isNaN()) return
+ val clamped = alpha.coerceIn(0f, 1f)
+ authoritative = when (val c = authoritative) {
+ is HslColor -> c.copy(alpha = clamped)
+ is RgbColor -> c.copy(alpha = clamped)
+ is CmykColor -> c.copy(alpha = clamped)
+ is LabColor -> c.copy(alpha = clamped)
+ }
+ }
+
+ // ---- ARGB Int update ----
+
+ /** Sets the color from a packed ARGB Int; RGB becomes the origin space. */
+ public fun updateFromArgbInt(argb: Int) {
+ authoritative = argb.toRgbColor()
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColoringMode.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColoringMode.kt
new file mode 100644
index 0000000..39585b5
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColoringMode.kt
@@ -0,0 +1,20 @@
+package codes.side.colorpicker.state
+
+/**
+ * Controls how a slider's gradient track is rendered with respect to other channels.
+ *
+ * - [Independent]: the gradient shows the component's full theoretical range,
+ * independent of the other channels. E.g. the hue slider always shows the full
+ * rainbow regardless of saturation and lightness.
+ *
+ * - [Contextual]: the gradient shows what the resulting color would be at each
+ * position, contextual on the current values of the other channels. E.g. the
+ * hue slider shows the rainbow rendered at the current saturation and lightness.
+ */
+public enum class ColoringMode {
+ /** Gradient shows the channel's full theoretical range, ignoring other channels. */
+ Independent,
+
+ /** Gradient shows the resulting color at each position given the other channels. */
+ Contextual,
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/RememberColorPickerState.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/RememberColorPickerState.kt
new file mode 100644
index 0000000..90c4ccb
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/RememberColorPickerState.kt
@@ -0,0 +1,22 @@
+package codes.side.colorpicker.state
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.PickerColor
+
+/**
+ * Creates and remembers a [ColorPickerState] for the lifetime of the composition.
+ *
+ * [initialColor] is read only once, when the state is first created; passing a
+ * different value on later recompositions does NOT reset the state (matching the
+ * `rememberScrollState` convention). Its runtime type selects the initial origin
+ * space. The state does not survive configuration changes or process death — use
+ * [rememberSaveableColorPickerState] for that.
+ */
+@Composable
+public fun rememberColorPickerState(
+ initialColor: PickerColor = HslColor(),
+): ColorPickerState {
+ return remember { ColorPickerState(initialColor) }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/SaveableColorPickerState.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/SaveableColorPickerState.kt
new file mode 100644
index 0000000..e75457f
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/SaveableColorPickerState.kt
@@ -0,0 +1,124 @@
+package codes.side.colorpicker.state
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.PickerColor
+import codes.side.colorpicker.model.RgbColor
+
+// Stable keys identifying the persisted color space. These values are part of
+// the saved-state format — never renumber or reuse them.
+private const val SPACE_KEY_HSL = 0f
+private const val SPACE_KEY_RGB = 1f
+private const val SPACE_KEY_CMYK = 2f
+private const val SPACE_KEY_LAB = 3f
+
+private const val SAVED_ARRAY_SIZE = 6
+
+/**
+ * Encodes the authoritative space and its native components in a single FloatArray.
+ *
+ * Layout: `[spaceKey, c0, c1, c2, c3, c4]`
+ * - `spaceKey` = stable space key (0=HSL, 1=RGB, 2=CMYK, 3=LAB)
+ * - HSL: c0=hue, c1=saturation, c2=lightness, c3=alpha, c4 unused
+ * - RGB: c0=red, c1=green, c2=blue, c3=alpha, c4 unused
+ * - CMYK: c0=cyan, c1=magenta, c2=yellow, c3=key, c4=alpha
+ * - LAB: c0=l, c1=a, c2=b, c3=alpha, c4 unused
+ *
+ * This preserves the authoritative space across process death so the user's
+ * "origin" choice survives rotation, not just the visible color.
+ *
+ * Restoring invalid data (unknown space key, wrong array size, or out-of-range
+ * channels) returns `null` per the [Saver] contract instead of throwing.
+ */
+internal val ColorPickerStateSaver = Saver(
+ save = { state ->
+ when (val color = state.pickerColor) {
+ is HslColor -> floatArrayOf(
+ SPACE_KEY_HSL,
+ color.hue, color.saturation, color.lightness, color.alpha, 0f,
+ )
+
+ is RgbColor -> floatArrayOf(
+ SPACE_KEY_RGB,
+ color.red, color.green, color.blue, color.alpha, 0f,
+ )
+
+ is CmykColor -> floatArrayOf(
+ SPACE_KEY_CMYK,
+ color.cyan, color.magenta, color.yellow, color.key, color.alpha,
+ )
+
+ is LabColor -> floatArrayOf(
+ SPACE_KEY_LAB,
+ color.l, color.a, color.b, color.alpha, 0f,
+ )
+ }
+ },
+ restore = { array ->
+ val color: PickerColor? = if (array.size != SAVED_ARRAY_SIZE) {
+ null
+ } else {
+ try {
+ when (array[0]) {
+ SPACE_KEY_HSL -> HslColor(
+ hue = array[1],
+ saturation = array[2],
+ lightness = array[3],
+ alpha = array[4],
+ )
+
+ SPACE_KEY_RGB -> RgbColor(
+ red = array[1],
+ green = array[2],
+ blue = array[3],
+ alpha = array[4],
+ )
+
+ SPACE_KEY_CMYK -> CmykColor(
+ cyan = array[1],
+ magenta = array[2],
+ yellow = array[3],
+ key = array[4],
+ alpha = array[5],
+ )
+
+ SPACE_KEY_LAB -> LabColor(
+ l = array[1],
+ a = array[2],
+ b = array[3],
+ alpha = array[4],
+ )
+
+ else -> null
+ }
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
+ color?.let { ColorPickerState(it) }
+ }
+)
+
+/**
+ * Like [rememberColorPickerState], but the state survives configuration changes
+ * and process death (where supported by the platform).
+ *
+ * [initialColor] is read only once, when the state is first created; passing a
+ * different value on later recompositions does NOT reset the state (matching the
+ * `rememberScrollState` convention). The [Saver] persists the authoritative color's
+ * native channels together with its color space, so both the visible color and the
+ * user's origin-space choice are restored; [ColorPickerState.isInteracting] is
+ * transient and not persisted.
+ */
+@Composable
+public fun rememberSaveableColorPickerState(
+ initialColor: PickerColor = HslColor(),
+): ColorPickerState {
+ return rememberSaveable(saver = ColorPickerStateSaver) {
+ ColorPickerState(initialColor)
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerColors.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerColors.kt
new file mode 100644
index 0000000..68f1250
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerColors.kt
@@ -0,0 +1,17 @@
+package codes.side.colorpicker.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Colors used by color picker components. Obtain instances via
+ * [ColorPickerDefaults.colors] so defaults come from a single source.
+ *
+ * @property checkerboardLight color of the light cells of the transparency checkerboard.
+ * @property checkerboardDark color of the dark cells of the transparency checkerboard.
+ */
+@Immutable
+public data class ColorPickerColors(
+ public val checkerboardLight: Color,
+ public val checkerboardDark: Color,
+)
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerDefaults.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerDefaults.kt
new file mode 100644
index 0000000..4f694cd
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerDefaults.kt
@@ -0,0 +1,51 @@
+package codes.side.colorpicker.theme
+
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * Default values used by color picker components.
+ *
+ * The composable factories read the ambient [MaterialTheme] at the call site, so the
+ * defaults automatically follow the app's color scheme and shape system. Pass explicit
+ * arguments to override individual values.
+ */
+public object ColorPickerDefaults {
+
+ /** Default height of a color slider's gradient track. */
+ public val TrackHeight: Dp = 16.dp
+
+ // surfaceBright/surfaceDim keep visible checkerboard contrast in both
+ // light and dark color schemes.
+ /**
+ * Creates a [ColorPickerColors] with defaults taken from
+ * `MaterialTheme.colorScheme` (`surfaceBright`/`surfaceDim` for the
+ * transparency checkerboard cells).
+ */
+ @Composable
+ public fun colors(
+ checkerboardLight: Color = MaterialTheme.colorScheme.surfaceBright,
+ checkerboardDark: Color = MaterialTheme.colorScheme.surfaceDim,
+ ): ColorPickerColors = ColorPickerColors(
+ checkerboardLight = checkerboardLight,
+ checkerboardDark = checkerboardDark,
+ )
+
+ /**
+ * Creates a [ColorPickerShapes] with a fully rounded slider track and a swatch
+ * shape taken from `MaterialTheme.shapes.small`.
+ */
+ @Composable
+ public fun shapes(
+ trackShape: Shape = CircleShape,
+ swatchShape: Shape = MaterialTheme.shapes.small,
+ ): ColorPickerShapes = ColorPickerShapes(
+ trackShape = trackShape,
+ swatchShape = swatchShape,
+ )
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerShapes.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerShapes.kt
new file mode 100644
index 0000000..a7c1aab
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerShapes.kt
@@ -0,0 +1,17 @@
+package codes.side.colorpicker.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Shape
+
+/**
+ * Shapes used by color picker components. Obtain instances via
+ * [ColorPickerDefaults.shapes] so defaults come from a single source.
+ *
+ * @property trackShape outer shape of a slider's gradient track.
+ * @property swatchShape shape of a [codes.side.colorpicker.ui.ColorSwatch].
+ */
+@Immutable
+public data class ColorPickerShapes(
+ public val trackShape: Shape,
+ public val swatchShape: Shape,
+)
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/AlphaSlider.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/AlphaSlider.kt
new file mode 100644
index 0000000..9d0db4f
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/AlphaSlider.kt
@@ -0,0 +1,62 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import codes.side.colorpicker.conversion.toComposeColor
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * Slider for the alpha (opacity) channel of [state], from transparent to opaque over
+ * a transparency checkerboard. Updating alpha keeps the state's current origin space.
+ *
+ * @param semanticLabel accessibility description of the slider; pass a localized
+ * string to replace the English default.
+ * @param semanticValueText accessibility announcement of the current value (`0..255`).
+ */
+@Composable
+public fun AlphaSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ label: (@Composable () -> Unit)? = { SliderLabel("Alpha") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.hslColor.intAlpha}") },
+ semanticLabel: String? = "Alpha",
+ semanticValueText: String? = "${state.hslColor.intAlpha}",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val hsl = state.hslColor
+ val opaqueColor = remember(hsl.hue, hsl.saturation, hsl.lightness) {
+ hsl.copy(alpha = 1f).toComposeColor()
+ }
+ // Fade the current hue from transparent to opaque so the gradient previews
+ // the actual color instead of fading through transparent black.
+ val gradientColors = remember(opaqueColor) {
+ persistentListOf(opaqueColor.copy(alpha = 0f), opaqueColor)
+ }
+ val thumbColor = remember(hsl) { hsl.toComposeColor() }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = hsl.alpha,
+ onValueChange = {
+ interaction.begin()
+ state.updateAlpha(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ showCheckerboard = true,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/CheckerboardBrush.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/CheckerboardBrush.kt
new file mode 100644
index 0000000..49c6333
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/CheckerboardBrush.kt
@@ -0,0 +1,47 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Canvas
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.ImageShader
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.TileMode
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+internal val CheckerboardCellSize = 6.dp
+
+/**
+ * Returns a [Brush] that tiles a transparency checkerboard pattern.
+ *
+ * A 2x2-cell tile is rendered once into an [ImageBitmap] and repeated via
+ * [ImageShader], so drawing the pattern is a single `drawRect(brush)` call
+ * instead of a per-cell rect loop on every frame.
+ */
+@Composable
+internal fun rememberCheckerboardBrush(
+ cellSize: Dp,
+ light: Color,
+ dark: Color,
+): Brush {
+ val cellSizePx = with(LocalDensity.current) { cellSize.roundToPx() }.coerceAtLeast(1)
+ return remember(cellSizePx, light, dark) {
+ val tileSizePx = cellSizePx * 2
+ val bitmap = ImageBitmap(tileSizePx, tileSizePx)
+ val canvas = Canvas(bitmap)
+ val paint = Paint()
+ val cell = cellSizePx.toFloat()
+ paint.color = light
+ canvas.drawRect(0f, 0f, cell, cell, paint)
+ canvas.drawRect(cell, cell, cell * 2f, cell * 2f, paint)
+ paint.color = dark
+ canvas.drawRect(cell, 0f, cell * 2f, cell, paint)
+ canvas.drawRect(0f, cell, cell, cell * 2f, paint)
+ ShaderBrush(ImageShader(bitmap, TileMode.Repeated, TileMode.Repeated))
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/CmykColorPicker.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/CmykColorPicker.kt
new file mode 100644
index 0000000..5aca655
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/CmykColorPicker.kt
@@ -0,0 +1,45 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+
+/**
+ * Complete CMYK picker: cyan, magenta, yellow, and key sliders, plus an optional
+ * alpha slider.
+ *
+ * @param showAlpha whether to include the [AlphaSlider].
+ * @param coloringMode defaults to [ColoringMode.Contextual] so each track previews
+ * the resulting color at the current values of the other channels.
+ * @param colors checkerboard colors; see [ColorPickerDefaults.colors].
+ * @param shapes track shape; see [ColorPickerDefaults.shapes].
+ */
+@Composable
+public fun CmykColorPicker(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ showAlpha: Boolean = true,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ CyanSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ MagentaSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ YellowSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ KeySlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ if (showAlpha) {
+ AlphaSlider(state = state, colors = colors, shapes = shapes)
+ }
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/CmykSliders.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/CmykSliders.kt
new file mode 100644
index 0000000..ff1d022
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/CmykSliders.kt
@@ -0,0 +1,254 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import codes.side.colorpicker.conversion.toComposeColor
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+import kotlinx.collections.immutable.persistentListOf
+
+private val PureCyan = CmykColor(cyan = 1f, magenta = 0f, yellow = 0f, key = 0f).toComposeColor()
+private val PureMagenta = CmykColor(cyan = 0f, magenta = 1f, yellow = 0f, key = 0f).toComposeColor()
+private val PureYellow = CmykColor(cyan = 0f, magenta = 0f, yellow = 1f, key = 0f).toComposeColor()
+
+/**
+ * Slider for the CMYK cyan channel of [state], in `0..1` (displayed as `0..100`%).
+ *
+ * @param coloringMode with [ColoringMode.Contextual] (the default) the track previews
+ * the resulting color at the current magenta, yellow, and key; with
+ * [ColoringMode.Independent] it runs from white to pure cyan.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in percent.
+ */
+@Composable
+public fun CyanSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ label: (@Composable () -> Unit)? = { SliderLabel("Cyan") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.cmykColor.intCyan}%") },
+ semanticLabel: String? = "Cyan",
+ semanticValueText: String? = "${state.cmykColor.intCyan}%",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val cmyk = state.cmykColor
+ val gradientColors = remember(cmyk.magenta, cmyk.yellow, cmyk.key, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> persistentListOf(Color.White, PureCyan)
+ ColoringMode.Contextual -> persistentListOf(
+ CmykColor(cyan = 0f, magenta = cmyk.magenta, yellow = cmyk.yellow, key = cmyk.key).toComposeColor(),
+ CmykColor(cyan = 1f, magenta = cmyk.magenta, yellow = cmyk.yellow, key = cmyk.key).toComposeColor(),
+ )
+ }
+ }
+ val thumbColor = remember(cmyk, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> CmykColor(cyan = cmyk.cyan, magenta = 0f, yellow = 0f, key = 0f).toComposeColor()
+ ColoringMode.Contextual -> cmyk.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = cmyk.cyan,
+ onValueChange = {
+ interaction.begin()
+ state.updateCyan(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
+
+/**
+ * Slider for the CMYK magenta channel of [state], in `0..1` (displayed as `0..100`%).
+ *
+ * @param coloringMode with [ColoringMode.Contextual] (the default) the track previews
+ * the resulting color at the current cyan, yellow, and key; with
+ * [ColoringMode.Independent] it runs from white to pure magenta.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in percent.
+ */
+@Composable
+public fun MagentaSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ label: (@Composable () -> Unit)? = { SliderLabel("Magenta") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.cmykColor.intMagenta}%") },
+ semanticLabel: String? = "Magenta",
+ semanticValueText: String? = "${state.cmykColor.intMagenta}%",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val cmyk = state.cmykColor
+ val gradientColors = remember(cmyk.cyan, cmyk.yellow, cmyk.key, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> persistentListOf(Color.White, PureMagenta)
+ ColoringMode.Contextual -> persistentListOf(
+ CmykColor(cyan = cmyk.cyan, magenta = 0f, yellow = cmyk.yellow, key = cmyk.key).toComposeColor(),
+ CmykColor(cyan = cmyk.cyan, magenta = 1f, yellow = cmyk.yellow, key = cmyk.key).toComposeColor(),
+ )
+ }
+ }
+ val thumbColor = remember(cmyk, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> CmykColor(cyan = 0f, magenta = cmyk.magenta, yellow = 0f, key = 0f).toComposeColor()
+ ColoringMode.Contextual -> cmyk.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = cmyk.magenta,
+ onValueChange = {
+ interaction.begin()
+ state.updateMagenta(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
+
+/**
+ * Slider for the CMYK yellow channel of [state], in `0..1` (displayed as `0..100`%).
+ *
+ * @param coloringMode with [ColoringMode.Contextual] (the default) the track previews
+ * the resulting color at the current cyan, magenta, and key; with
+ * [ColoringMode.Independent] it runs from white to pure yellow.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in percent.
+ */
+@Composable
+public fun YellowSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ label: (@Composable () -> Unit)? = { SliderLabel("Yellow") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.cmykColor.intYellow}%") },
+ semanticLabel: String? = "Yellow",
+ semanticValueText: String? = "${state.cmykColor.intYellow}%",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val cmyk = state.cmykColor
+ val gradientColors = remember(cmyk.cyan, cmyk.magenta, cmyk.key, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> persistentListOf(Color.White, PureYellow)
+ ColoringMode.Contextual -> persistentListOf(
+ CmykColor(cyan = cmyk.cyan, magenta = cmyk.magenta, yellow = 0f, key = cmyk.key).toComposeColor(),
+ CmykColor(cyan = cmyk.cyan, magenta = cmyk.magenta, yellow = 1f, key = cmyk.key).toComposeColor(),
+ )
+ }
+ }
+ val thumbColor = remember(cmyk, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> CmykColor(cyan = 0f, magenta = 0f, yellow = cmyk.yellow, key = 0f).toComposeColor()
+ ColoringMode.Contextual -> cmyk.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = cmyk.yellow,
+ onValueChange = {
+ interaction.begin()
+ state.updateYellow(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
+
+/**
+ * Slider for the CMYK key (black) channel of [state], in `0..1` (displayed as `0..100`%).
+ *
+ * @param coloringMode with [ColoringMode.Contextual] (the default) the track previews
+ * the resulting color at the current cyan, magenta, and yellow; with
+ * [ColoringMode.Independent] it runs from white to black.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in percent.
+ */
+@Composable
+public fun KeySlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ label: (@Composable () -> Unit)? = { SliderLabel("Key") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.cmykColor.intKey}%") },
+ semanticLabel: String? = "Key",
+ semanticValueText: String? = "${state.cmykColor.intKey}%",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val cmyk = state.cmykColor
+ val gradientColors = remember(cmyk.cyan, cmyk.magenta, cmyk.yellow, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> persistentListOf(Color.White, Color.Black)
+ ColoringMode.Contextual -> persistentListOf(
+ CmykColor(cyan = cmyk.cyan, magenta = cmyk.magenta, yellow = cmyk.yellow, key = 0f).toComposeColor(),
+ CmykColor(cyan = cmyk.cyan, magenta = cmyk.magenta, yellow = cmyk.yellow, key = 1f).toComposeColor(),
+ )
+ }
+ }
+ val thumbColor = remember(cmyk, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> CmykColor(cyan = 0f, magenta = 0f, yellow = 0f, key = cmyk.key).toComposeColor()
+ ColoringMode.Contextual -> cmyk.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = cmyk.key,
+ onValueChange = {
+ interaction.begin()
+ state.updateKey(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorPickerDialog.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorPickerDialog.kt
new file mode 100644
index 0000000..aa00798
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorPickerDialog.kt
@@ -0,0 +1,90 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import codes.side.colorpicker.conversion.toComposeColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColorPickerStateSaver
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+
+/**
+ * [AlertDialog] hosting an [HslColorPicker] with a preview swatch and
+ * confirm/dismiss buttons.
+ *
+ * The dialog owns its picker state, saved with [initialColor] as the key: in-progress
+ * edits survive configuration changes and process death, while passing a different
+ * [initialColor] recreates the state at that color.
+ *
+ * @param onColorSelected called with the chosen color when the confirm button is
+ * pressed; the caller is responsible for dismissing the dialog.
+ * @param onDismiss called when the user cancels or dismisses the dialog.
+ * @param title dialog title text; [confirmText] and [dismissText] label the buttons —
+ * pass localized strings to replace the English defaults.
+ * @param showAlpha whether to include the alpha slider.
+ * @param colors checkerboard colors used by both the swatch and the picker's alpha
+ * slider; see [ColorPickerDefaults.colors].
+ */
+@Composable
+public fun ColorPickerDialog(
+ onColorSelected: (HslColor) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ initialColor: HslColor = HslColor(),
+ title: String = "Pick a Color",
+ confirmText: String = "Select",
+ dismissText: String = "Cancel",
+ showAlpha: Boolean = true,
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+) {
+ // initialColor is a reset key: a new initial color re-creates the state,
+ // while configuration changes restore in-progress edits via the saver.
+ val state = rememberSaveable(initialColor, saver = ColorPickerStateSaver) {
+ ColorPickerState(initialColor)
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = { Text(title) },
+ text = {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ ) {
+ ColorSwatch(
+ color = state.hslColor.toComposeColor(),
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ colors = colors,
+ )
+ Spacer(Modifier.height(16.dp))
+ HslColorPicker(
+ state = state,
+ showAlpha = showAlpha,
+ colors = colors,
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onColorSelected(state.hslColor) }) {
+ Text(confirmText)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(dismissText)
+ }
+ },
+ )
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorSlider.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorSlider.kt
new file mode 100644
index 0000000..230e657
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorSlider.kt
@@ -0,0 +1,114 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+import kotlinx.collections.immutable.ImmutableList
+
+/**
+ * Building block for a single-channel color slider: an M3 [Slider] with a gradient
+ * track, optional label row, and optional transparency checkerboard.
+ *
+ * All built-in channel sliders (hue, red, alpha, ...) are thin wrappers around this;
+ * use it directly to build a custom channel.
+ *
+ * @param value current position in `0..1`; callers map their channel range to this.
+ * @param gradientColors color stops of the track gradient, from `0` to `1`.
+ * @param label optional slot shown above the track's start; see [SliderLabel].
+ * @param valueLabel optional slot shown above the track's end; see [SliderValueLabel].
+ * @param showCheckerboard draws a transparency checkerboard under the gradient, for
+ * gradients with translucent stops (used by [AlphaSlider]).
+ * @param semanticLabel accessibility content description of the slider (what channel
+ * it controls); merged with the M3 slider's own progress semantics.
+ * @param semanticValueText accessibility state description of the current value, for
+ * announcing the channel's native units instead of a raw fraction.
+ * @param colors checkerboard colors; see [ColorPickerDefaults.colors].
+ * @param shapes track shape; see [ColorPickerDefaults.shapes].
+ */
+@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
+@Composable
+public fun ColorSlider(
+ value: Float,
+ onValueChange: (Float) -> Unit,
+ gradientColors: ImmutableList,
+ thumbColor: Color,
+ modifier: Modifier = Modifier,
+ label: (@Composable () -> Unit)? = null,
+ valueLabel: (@Composable () -> Unit)? = null,
+ onValueChangeFinished: (() -> Unit)? = null,
+ trackHeight: Dp = ColorPickerDefaults.TrackHeight,
+ showCheckerboard: Boolean = false,
+ semanticLabel: String? = null,
+ semanticValueText: String? = null,
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+
+ Column(modifier = modifier.fillMaxWidth()) {
+ if (label != null || valueLabel != null) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Box { label?.invoke() }
+ Box { valueLabel?.invoke() }
+ }
+ }
+
+ Slider(
+ value = value,
+ onValueChange = onValueChange,
+ onValueChangeFinished = onValueChangeFinished,
+ modifier = Modifier
+ .fillMaxWidth()
+ // Merged semantics only — M3 Slider's own progress semantics
+ // must survive.
+ .semantics {
+ semanticLabel?.let { contentDescription = it }
+ semanticValueText?.let { stateDescription = it }
+ },
+ interactionSource = interactionSource,
+ track = {
+ GradientTrack(
+ colors = gradientColors,
+ thumbFraction = value,
+ interactionSource = interactionSource,
+ checkerboardLight = colors.checkerboardLight,
+ checkerboardDark = colors.checkerboardDark,
+ trackShape = shapes.trackShape,
+ showCheckerboard = showCheckerboard,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(trackHeight),
+ )
+ },
+ colors = SliderDefaults.colors(
+ thumbColor = thumbColor,
+ activeTrackColor = Color.Transparent,
+ inactiveTrackColor = Color.Transparent,
+ ),
+ )
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorSwatch.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorSwatch.kt
new file mode 100644
index 0000000..e4b0a1b
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorSwatch.kt
@@ -0,0 +1,44 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+
+/**
+ * Displays [color] clipped to [shape], over a transparency checkerboard so
+ * translucent colors read correctly.
+ *
+ * @param contentDescription optional accessibility description of the shown color;
+ * when `null` the swatch is decorative.
+ * @param colors checkerboard colors; see [ColorPickerDefaults.colors].
+ */
+@Composable
+public fun ColorSwatch(
+ color: Color,
+ modifier: Modifier = Modifier,
+ shape: Shape = ColorPickerDefaults.shapes().swatchShape,
+ contentDescription: String? = null,
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+) {
+ Box(
+ modifier = modifier
+ .clip(shape)
+ .semantics {
+ if (contentDescription != null) {
+ this.contentDescription = contentDescription
+ }
+ },
+ ) {
+ TransparencyCheckerboard(Modifier.fillMaxSize(), colors = colors)
+ Box(Modifier.fillMaxSize().background(color))
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/GradientTrack.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/GradientTrack.kt
new file mode 100644
index 0000000..802617a
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/GradientTrack.kt
@@ -0,0 +1,147 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.interaction.DragInteraction
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.RoundRect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.drawscope.clipPath
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlinx.collections.immutable.ImmutableList
+
+// M3 token defaults (from SliderTokens)
+private val HandleWidth = 4.dp
+private val PressedHandleWidth = 2.dp
+private val ThumbTrackGapSize = 6.dp
+private val TrackInsideCornerSize = 2.dp
+
+/**
+ * Gradient track with an M3-style gap around the thumb.
+ *
+ * The gap shrinks when the thumb is pressed/dragged (matching M3's behavior where
+ * the thumb narrows on interaction, causing the track to come closer). The track's
+ * outer corners come from [trackShape]; in right-to-left layouts the gradient and
+ * the thumb gap are mirrored to match the mirrored M3 [androidx.compose.material3.Slider].
+ *
+ * @param showCheckerboard if true, draws a transparency checkerboard underneath
+ * the gradient (only within the track segments). Used by [AlphaSlider].
+ */
+@Composable
+internal fun GradientTrack(
+ colors: ImmutableList,
+ thumbFraction: Float,
+ interactionSource: MutableInteractionSource,
+ checkerboardLight: Color,
+ checkerboardDark: Color,
+ trackShape: Shape,
+ modifier: Modifier = Modifier,
+ showCheckerboard: Boolean = false,
+) {
+ val interactions = remember { mutableStateListOf() }
+ LaunchedEffect(interactionSource) {
+ interactionSource.interactions.collect { interaction ->
+ when (interaction) {
+ is PressInteraction.Press -> interactions.add(interaction)
+ is PressInteraction.Release -> interactions.remove(interaction.press)
+ is PressInteraction.Cancel -> interactions.remove(interaction.press)
+ is DragInteraction.Start -> interactions.add(interaction)
+ is DragInteraction.Stop -> interactions.remove(interaction.start)
+ is DragInteraction.Cancel -> interactions.remove(interaction.start)
+ }
+ }
+ }
+
+ val isActive = interactions.isNotEmpty()
+ val currentThumbWidth = if (isActive) PressedHandleWidth else HandleWidth
+
+ val layoutDirection = LocalLayoutDirection.current
+ val brush = remember(colors, layoutDirection) {
+ Brush.horizontalGradient(
+ if (layoutDirection == LayoutDirection.Rtl) colors.reversed() else colors,
+ )
+ }
+ val checkerboardBrush = if (showCheckerboard) {
+ rememberCheckerboardBrush(
+ cellSize = CheckerboardCellSize,
+ light = checkerboardLight,
+ dark = checkerboardDark,
+ )
+ } else {
+ null
+ }
+ val segmentsPath = remember { Path() }
+
+ Canvas(modifier = modifier.clip(trackShape)) {
+ val insideCornerSize = TrackInsideCornerSize.toPx()
+ val gap = currentThumbWidth.toPx() / 2f + ThumbTrackGapSize.toPx()
+ val fractionCenter = thumbFraction.coerceIn(0f, 1f) * size.width
+ // M3 Slider mirrors the thumb position in RTL, so the gap must mirror
+ // to stay underneath the thumb.
+ val thumbCenter = if (this.layoutDirection == LayoutDirection.Rtl) {
+ size.width - fractionCenter
+ } else {
+ fractionCenter
+ }
+
+ val leftEnd = thumbCenter - gap
+ val rightStart = thumbCenter + gap
+
+ // Outer corners are clipped by trackShape; only the corners facing the
+ // thumb gap are rounded here.
+ segmentsPath.reset()
+
+ if (leftEnd > 0f) {
+ segmentsPath.addRoundRect(
+ RoundRect(
+ rect = Rect(
+ offset = Offset.Zero,
+ size = Size(leftEnd, size.height),
+ ),
+ topLeft = CornerRadius.Zero,
+ bottomLeft = CornerRadius.Zero,
+ topRight = CornerRadius(insideCornerSize),
+ bottomRight = CornerRadius(insideCornerSize),
+ )
+ )
+ }
+
+ if (rightStart < size.width) {
+ segmentsPath.addRoundRect(
+ RoundRect(
+ rect = Rect(
+ offset = Offset(rightStart, 0f),
+ size = Size(size.width - rightStart, size.height),
+ ),
+ topLeft = CornerRadius(insideCornerSize),
+ bottomLeft = CornerRadius(insideCornerSize),
+ topRight = CornerRadius.Zero,
+ bottomRight = CornerRadius.Zero,
+ )
+ )
+ }
+
+ clipPath(segmentsPath) {
+ if (checkerboardBrush != null) {
+ drawRect(brush = checkerboardBrush)
+ }
+ drawRect(brush = brush)
+ }
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/HslColorPicker.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/HslColorPicker.kt
new file mode 100644
index 0000000..0de8297
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/HslColorPicker.kt
@@ -0,0 +1,48 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+
+/**
+ * Complete HSL picker: hue, saturation, and lightness sliders, plus an optional
+ * alpha slider.
+ *
+ * @param showAlpha whether to include the [AlphaSlider].
+ * @param coloringMode defaults to [ColoringMode.Independent], unlike the
+ * RGB/CMYK/LAB pickers, because the hue slider must always show the full spectrum
+ * to stay navigable — at low saturation or extreme lightness a contextual hue
+ * track collapses into a near-uniform strip.
+ * @param colors checkerboard colors; see [ColorPickerDefaults.colors].
+ * @param shapes track shape; see [ColorPickerDefaults.shapes].
+ */
+@Composable
+public fun HslColorPicker(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ showAlpha: Boolean = true,
+ // Independent by default (unlike the RGB/CMYK/LAB pickers): the hue slider
+ // must always show the full spectrum to stay navigable.
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ HueSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ SaturationSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ LightnessSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ if (showAlpha) {
+ AlphaSlider(state = state, colors = colors, shapes = shapes)
+ }
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/HslSliders.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/HslSliders.kt
new file mode 100644
index 0000000..aee7e5a
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/HslSliders.kt
@@ -0,0 +1,228 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import codes.side.colorpicker.conversion.toComposeColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+// The largest hue the slider ever writes. HslColor normalizes hue 360 to the
+// equivalent 0, so writing exactly 360 at the right end of the track would snap
+// the thumb back to the far left mid-drag; mapping over 0..360 (exclusive)
+// keeps the right end a stable thumb position.
+// Float.nextDown() is not available in common code; step to the previous
+// representable Float via its bit pattern instead.
+private val MAX_SLIDER_HUE = Float.fromBits(360f.toRawBits() - 1)
+
+/** Maps a slider fraction in `0..1` to a hue in `0..360` (exclusive). */
+internal fun hueFromFraction(fraction: Float): Float =
+ (fraction * 360f).coerceAtMost(MAX_SLIDER_HUE)
+
+private val HUE_RAINBOW = persistentListOf(
+ Color.Red,
+ Color.Yellow,
+ Color.Green,
+ Color.Cyan,
+ Color.Blue,
+ Color.Magenta,
+ Color.Red,
+)
+
+/**
+ * Slider for the HSL hue channel of [state], in degrees `0..360`.
+ *
+ * @param coloringMode with [ColoringMode.Independent] (the default) the track always
+ * shows the full rainbow; with [ColoringMode.Contextual] it is rendered at the current
+ * saturation and lightness.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in degrees.
+ */
+@Composable
+public fun HueSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ label: (@Composable () -> Unit)? = { SliderLabel("Hue") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.hslColor.intHue}°") },
+ semanticLabel: String? = "Hue",
+ semanticValueText: String? = "${state.hslColor.intHue}°",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val hsl = state.hslColor
+ val gradientColors = remember(hsl.saturation, hsl.lightness, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> HUE_RAINBOW
+ ColoringMode.Contextual -> buildHueGradient(hsl.saturation, hsl.lightness)
+ }
+ }
+ val thumbColor = remember(hsl, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> HslColor(hue = hsl.hue, saturation = 1f, lightness = 0.5f).toComposeColor()
+ ColoringMode.Contextual -> hsl.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = hsl.hue / 360f,
+ onValueChange = {
+ interaction.begin()
+ state.updateHue(hueFromFraction(it))
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
+
+/**
+ * Slider for the HSL saturation channel of [state], in `0..1`.
+ *
+ * @param coloringMode with [ColoringMode.Independent] (the default) the track runs from
+ * gray to the pure hue at mid lightness; with [ColoringMode.Contextual] it is rendered
+ * at the current lightness.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in percent.
+ */
+@Composable
+public fun SaturationSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ label: (@Composable () -> Unit)? = { SliderLabel("Saturation") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.hslColor.intSaturation}%") },
+ semanticLabel: String? = "Saturation",
+ semanticValueText: String? = "${state.hslColor.intSaturation}%",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val hsl = state.hslColor
+ val gradientColors = remember(hsl.hue, hsl.lightness, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> persistentListOf(
+ Color.Gray,
+ HslColor(hue = hsl.hue, saturation = 1f, lightness = 0.5f).toComposeColor(),
+ )
+ ColoringMode.Contextual -> persistentListOf(
+ HslColor(hue = hsl.hue, saturation = 0f, lightness = hsl.lightness).toComposeColor(),
+ HslColor(hue = hsl.hue, saturation = 1f, lightness = hsl.lightness).toComposeColor(),
+ )
+ }
+ }
+ val thumbColor = remember(hsl, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> HslColor(hue = hsl.hue, saturation = hsl.saturation, lightness = 0.5f).toComposeColor()
+ ColoringMode.Contextual -> hsl.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = hsl.saturation,
+ onValueChange = {
+ interaction.begin()
+ state.updateSaturation(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
+
+/**
+ * Slider for the HSL lightness channel of [state], in `0..1`, from black through the
+ * hue to white.
+ *
+ * @param coloringMode with [ColoringMode.Independent] (the default) the track's midpoint
+ * is the pure hue; with [ColoringMode.Contextual] it is rendered at the current saturation.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in percent.
+ */
+@Composable
+public fun LightnessSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ label: (@Composable () -> Unit)? = { SliderLabel("Lightness") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.hslColor.intLightness}%") },
+ semanticLabel: String? = "Lightness",
+ semanticValueText: String? = "${state.hslColor.intLightness}%",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val hsl = state.hslColor
+ val gradientColors = remember(hsl.hue, hsl.saturation, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> persistentListOf(
+ Color.Black,
+ HslColor(hue = hsl.hue, saturation = 1f, lightness = 0.5f).toComposeColor(),
+ Color.White,
+ )
+ ColoringMode.Contextual -> persistentListOf(
+ Color.Black,
+ HslColor(hue = hsl.hue, saturation = hsl.saturation, lightness = 0.5f).toComposeColor(),
+ Color.White,
+ )
+ }
+ }
+ val thumbColor = remember(hsl, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> HslColor(hue = hsl.hue, saturation = 1f, lightness = hsl.lightness).toComposeColor()
+ ColoringMode.Contextual -> hsl.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = hsl.lightness,
+ onValueChange = {
+ interaction.begin()
+ state.updateLightness(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
+
+private fun buildHueGradient(saturation: Float, lightness: Float): ImmutableList {
+ val steps = 7
+ return (0..steps).map { i ->
+ val hue = (i * 360f / steps).coerceAtMost(360f)
+ HslColor(hue = hue, saturation = saturation, lightness = lightness).toComposeColor()
+ }.toImmutableList()
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/LabColorPicker.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/LabColorPicker.kt
new file mode 100644
index 0000000..63bd3e7
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/LabColorPicker.kt
@@ -0,0 +1,43 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+
+/**
+ * Complete CIELAB picker: L*, a*, and b* sliders, plus an optional alpha slider.
+ *
+ * @param showAlpha whether to include the [AlphaSlider].
+ * @param coloringMode defaults to [ColoringMode.Contextual] so each track previews
+ * the resulting color at the current values of the other channels.
+ * @param colors checkerboard colors; see [ColorPickerDefaults.colors].
+ * @param shapes track shape; see [ColorPickerDefaults.shapes].
+ */
+@Composable
+public fun LabColorPicker(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ showAlpha: Boolean = true,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ LightnessLabSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ LabASlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ LabBSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ if (showAlpha) {
+ AlphaSlider(state = state, colors = colors, shapes = shapes)
+ }
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/LabSliders.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/LabSliders.kt
new file mode 100644
index 0000000..a190054
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/LabSliders.kt
@@ -0,0 +1,204 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import codes.side.colorpicker.conversion.toComposeColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+import kotlinx.collections.immutable.toImmutableList
+
+// Pre-computed independent (a=0, b=0) gradients
+private val LightnessIndependentGradient = (0..10).map { i ->
+ LabColor(l = i * 10f, a = 0f, b = 0f).toComposeColor()
+}.toImmutableList()
+
+private val AAxisIndependentGradient = (0..10).map { i ->
+ val a = -128f + (i * 255f / 10f)
+ LabColor(l = 50f, a = a.coerceIn(-128f, 127f), b = 0f).toComposeColor()
+}.toImmutableList()
+
+private val BAxisIndependentGradient = (0..10).map { i ->
+ val b = -128f + (i * 255f / 10f)
+ LabColor(l = 50f, a = 0f, b = b.coerceIn(-128f, 127f)).toComposeColor()
+}.toImmutableList()
+
+/**
+ * Slider for the CIELAB lightness (L*) channel of [state], in `0..100`.
+ *
+ * @param coloringMode with [ColoringMode.Contextual] (the default) the track previews
+ * the resulting color at the current a* and b*; with [ColoringMode.Independent] it
+ * shows the neutral gray ramp (a = 0, b = 0).
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value (`0..100`).
+ */
+@Composable
+public fun LightnessLabSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ label: (@Composable () -> Unit)? = { SliderLabel("L") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.labColor.intL}") },
+ semanticLabel: String? = "L*",
+ semanticValueText: String? = "${state.labColor.intL}",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val lab = state.labColor
+ val gradientColors = remember(lab.a, lab.b, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> LightnessIndependentGradient
+ ColoringMode.Contextual -> (0..10).map { i ->
+ LabColor(l = i * 10f, a = lab.a, b = lab.b).toComposeColor()
+ }.toImmutableList()
+ }
+ }
+ val thumbColor = remember(lab, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> LabColor(l = lab.l, a = 0f, b = 0f).toComposeColor()
+ ColoringMode.Contextual -> lab.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = lab.l / 100f,
+ onValueChange = {
+ interaction.begin()
+ state.updateLabLightness(it * 100f)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
+
+/**
+ * Slider for the CIELAB a* (green-red) axis of [state], in `-128..127`.
+ *
+ * @param coloringMode with [ColoringMode.Contextual] (the default) the track previews
+ * the resulting color at the current L* and b*; with [ColoringMode.Independent] it is
+ * rendered at mid lightness with b = 0.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value (`-128..127`).
+ */
+@Composable
+public fun LabASlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ label: (@Composable () -> Unit)? = { SliderLabel("a") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.labColor.intA}") },
+ semanticLabel: String? = "a*",
+ semanticValueText: String? = "${state.labColor.intA}",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val lab = state.labColor
+ val gradientColors = remember(lab.l, lab.b, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> AAxisIndependentGradient
+ ColoringMode.Contextual -> (0..10).map { i ->
+ val a = -128f + (i * 255f / 10f)
+ LabColor(l = lab.l, a = a.coerceIn(-128f, 127f), b = lab.b).toComposeColor()
+ }.toImmutableList()
+ }
+ }
+ val thumbColor = remember(lab, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> LabColor(l = 50f, a = lab.a, b = 0f).toComposeColor()
+ ColoringMode.Contextual -> lab.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = (lab.a + 128f) / 255f,
+ onValueChange = {
+ interaction.begin()
+ state.updateLabA(it * 255f - 128f)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
+
+/**
+ * Slider for the CIELAB b* (blue-yellow) axis of [state], in `-128..127`.
+ *
+ * @param coloringMode with [ColoringMode.Contextual] (the default) the track previews
+ * the resulting color at the current L* and a*; with [ColoringMode.Independent] it is
+ * rendered at mid lightness with a = 0.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value (`-128..127`).
+ */
+@Composable
+public fun LabBSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ label: (@Composable () -> Unit)? = { SliderLabel("b") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.labColor.intB}") },
+ semanticLabel: String? = "b*",
+ semanticValueText: String? = "${state.labColor.intB}",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val lab = state.labColor
+ val gradientColors = remember(lab.l, lab.a, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> BAxisIndependentGradient
+ ColoringMode.Contextual -> (0..10).map { i ->
+ val b = -128f + (i * 255f / 10f)
+ LabColor(l = lab.l, a = lab.a, b = b.coerceIn(-128f, 127f)).toComposeColor()
+ }.toImmutableList()
+ }
+ }
+ val thumbColor = remember(lab, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> LabColor(l = 50f, a = 0f, b = lab.b).toComposeColor()
+ ColoringMode.Contextual -> lab.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = (lab.b + 128f) / 255f,
+ onValueChange = {
+ interaction.begin()
+ state.updateLabB(it * 255f - 128f)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/RgbColorPicker.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/RgbColorPicker.kt
new file mode 100644
index 0000000..2533686
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/RgbColorPicker.kt
@@ -0,0 +1,43 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+
+/**
+ * Complete RGB picker: red, green, and blue sliders, plus an optional alpha slider.
+ *
+ * @param showAlpha whether to include the [AlphaSlider].
+ * @param coloringMode defaults to [ColoringMode.Contextual] so each track previews
+ * the resulting color at the current values of the other channels.
+ * @param colors checkerboard colors; see [ColorPickerDefaults.colors].
+ * @param shapes track shape; see [ColorPickerDefaults.shapes].
+ */
+@Composable
+public fun RgbColorPicker(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ showAlpha: Boolean = true,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ RedSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ GreenSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ BlueSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ if (showAlpha) {
+ AlphaSlider(state = state, colors = colors, shapes = shapes)
+ }
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/RgbSliders.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/RgbSliders.kt
new file mode 100644
index 0000000..48f27b5
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/RgbSliders.kt
@@ -0,0 +1,191 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import codes.side.colorpicker.conversion.toComposeColor
+import codes.side.colorpicker.model.RgbColor
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * Slider for the RGB red channel of [state], in `0..1` (displayed as `0..255`).
+ *
+ * @param coloringMode with [ColoringMode.Contextual] (the default) the track previews
+ * the resulting color at the current green and blue; with [ColoringMode.Independent]
+ * it runs from black to pure red.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value (`0..255`).
+ */
+@Composable
+public fun RedSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ label: (@Composable () -> Unit)? = { SliderLabel("Red") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.rgbColor.intRed}") },
+ semanticLabel: String? = "Red",
+ semanticValueText: String? = "${state.rgbColor.intRed}",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val rgb = state.rgbColor
+ val gradientColors = remember(rgb.green, rgb.blue, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> persistentListOf(Color.Black, Color.Red)
+ ColoringMode.Contextual -> persistentListOf(
+ RgbColor(red = 0f, green = rgb.green, blue = rgb.blue).toComposeColor(),
+ RgbColor(red = 1f, green = rgb.green, blue = rgb.blue).toComposeColor(),
+ )
+ }
+ }
+ val thumbColor = remember(rgb, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> RgbColor(red = rgb.red, green = 0f, blue = 0f).toComposeColor()
+ ColoringMode.Contextual -> rgb.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = rgb.red,
+ onValueChange = {
+ interaction.begin()
+ state.updateRed(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
+
+/**
+ * Slider for the RGB green channel of [state], in `0..1` (displayed as `0..255`).
+ *
+ * @param coloringMode with [ColoringMode.Contextual] (the default) the track previews
+ * the resulting color at the current red and blue; with [ColoringMode.Independent]
+ * it runs from black to pure green.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value (`0..255`).
+ */
+@Composable
+public fun GreenSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ label: (@Composable () -> Unit)? = { SliderLabel("Green") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.rgbColor.intGreen}") },
+ semanticLabel: String? = "Green",
+ semanticValueText: String? = "${state.rgbColor.intGreen}",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val rgb = state.rgbColor
+ val gradientColors = remember(rgb.red, rgb.blue, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> persistentListOf(Color.Black, Color.Green)
+ ColoringMode.Contextual -> persistentListOf(
+ RgbColor(red = rgb.red, green = 0f, blue = rgb.blue).toComposeColor(),
+ RgbColor(red = rgb.red, green = 1f, blue = rgb.blue).toComposeColor(),
+ )
+ }
+ }
+ val thumbColor = remember(rgb, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> RgbColor(red = 0f, green = rgb.green, blue = 0f).toComposeColor()
+ ColoringMode.Contextual -> rgb.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = rgb.green,
+ onValueChange = {
+ interaction.begin()
+ state.updateGreen(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
+
+/**
+ * Slider for the RGB blue channel of [state], in `0..1` (displayed as `0..255`).
+ *
+ * @param coloringMode with [ColoringMode.Contextual] (the default) the track previews
+ * the resulting color at the current red and green; with [ColoringMode.Independent]
+ * it runs from black to pure blue.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value (`0..255`).
+ */
+@Composable
+public fun BlueSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Contextual,
+ label: (@Composable () -> Unit)? = { SliderLabel("Blue") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.rgbColor.intBlue}") },
+ semanticLabel: String? = "Blue",
+ semanticValueText: String? = "${state.rgbColor.intBlue}",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ val rgb = state.rgbColor
+ val gradientColors = remember(rgb.red, rgb.green, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> persistentListOf(Color.Black, Color.Blue)
+ ColoringMode.Contextual -> persistentListOf(
+ RgbColor(red = rgb.red, green = rgb.green, blue = 0f).toComposeColor(),
+ RgbColor(red = rgb.red, green = rgb.green, blue = 1f).toComposeColor(),
+ )
+ }
+ }
+ val thumbColor = remember(rgb, coloringMode) {
+ when (coloringMode) {
+ ColoringMode.Independent -> RgbColor(red = 0f, green = 0f, blue = rgb.blue).toComposeColor()
+ ColoringMode.Contextual -> rgb.toComposeColor()
+ }
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = rgb.blue,
+ onValueChange = {
+ interaction.begin()
+ state.updateBlue(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ )
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SliderInteractionGuard.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SliderInteractionGuard.kt
new file mode 100644
index 0000000..059b6fb
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SliderInteractionGuard.kt
@@ -0,0 +1,44 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.runtime.RememberObserver
+import codes.side.colorpicker.state.ColorPickerState
+
+/**
+ * Manages [ColorPickerState.isInteracting] for a single slider's drag gesture.
+ *
+ * M3 Slider does not invoke `onValueChangeFinished` when its node is removed from
+ * composition mid-gesture (the pointer-input coroutine is simply cancelled), which
+ * would leave the flag stuck at `true` on a state that outlives the slider (e.g.
+ * hoisted in a view model). Remembering this guard makes the flag self-healing:
+ * [onForgotten] clears it if — and only if — this slider set it and the gesture
+ * never finished.
+ */
+internal class SliderInteractionGuard(private val state: ColorPickerState) : RememberObserver {
+
+ private var active = false
+
+ /** Call from `onValueChange`: marks the gesture active. */
+ fun begin() {
+ active = true
+ state.isInteracting = true
+ }
+
+ /** Call from `onValueChangeFinished`: marks the gesture finished. */
+ fun end() {
+ active = false
+ state.isInteracting = false
+ }
+
+ override fun onRemembered() {}
+
+ override fun onForgotten() {
+ // Disposed mid-drag: onValueChangeFinished will never fire. Clear the flag
+ // only if this slider was the one interacting, so disposing an idle slider
+ // never clobbers another slider's in-progress gesture.
+ if (active) end()
+ }
+
+ override fun onAbandoned() {
+ if (active) end()
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SliderLabelDefaults.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SliderLabelDefaults.kt
new file mode 100644
index 0000000..a4f2143
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SliderLabelDefaults.kt
@@ -0,0 +1,35 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.font.FontFamily
+
+/**
+ * Default label slot rendering used by built-in sliders. Renders [text] with
+ * `MaterialTheme.typography.labelMedium`.
+ *
+ * Exposed so that consumers overriding [ColorSlider]'s `label`/`valueLabel`
+ * slots can keep visual consistency with the default style.
+ */
+@Composable
+public fun SliderLabel(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium,
+ )
+}
+
+/**
+ * Default value-label slot rendering used by built-in sliders. Renders [text]
+ * with `MaterialTheme.typography.labelMedium` and a monospaced font for
+ * stable digit alignment as the value changes.
+ */
+@Composable
+public fun SliderValueLabel(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/TransparencyCheckerboard.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/TransparencyCheckerboard.kt
new file mode 100644
index 0000000..3236f08
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/TransparencyCheckerboard.kt
@@ -0,0 +1,25 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+
+/** Fills the available size with a tiled transparency checkerboard pattern. */
+@Composable
+internal fun TransparencyCheckerboard(
+ modifier: Modifier = Modifier,
+ cellSize: Dp = CheckerboardCellSize,
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+) {
+ val brush = rememberCheckerboardBrush(
+ cellSize = cellSize,
+ light = colors.checkerboardLight,
+ dark = colors.checkerboardDark,
+ )
+ Canvas(modifier = modifier) {
+ drawRect(brush = brush)
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/util/RandomColors.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/util/RandomColors.kt
new file mode 100644
index 0000000..b6800df
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/util/RandomColors.kt
@@ -0,0 +1,18 @@
+package codes.side.colorpicker.util
+
+import codes.side.colorpicker.model.HslColor
+import kotlin.random.Random
+
+/**
+ * Returns a random opaque [HslColor] with a uniformly random hue.
+ *
+ * @param pure if `true`, saturation is fixed at `1` and lightness at `0.5` (a fully
+ * saturated "pure" hue); if `false` (the default), both are also uniformly random.
+ */
+public fun randomHslColor(pure: Boolean = false): HslColor {
+ return HslColor(
+ hue = Random.nextFloat() * 360f,
+ saturation = if (pure) 1f else Random.nextFloat(),
+ lightness = if (pure) 0.5f else Random.nextFloat(),
+ )
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/ArgbIntTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/ArgbIntTest.kt
new file mode 100644
index 0000000..44b9b11
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/ArgbIntTest.kt
@@ -0,0 +1,166 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ArgbIntTest {
+
+ private fun assertIntRgbEqual(expected: RgbColor, actual: RgbColor) {
+ assertEquals(expected.intRed, actual.intRed, "red: ${expected.intRed} vs ${actual.intRed}")
+ assertEquals(expected.intGreen, actual.intGreen, "green: ${expected.intGreen} vs ${actual.intGreen}")
+ assertEquals(expected.intBlue, actual.intBlue, "blue: ${expected.intBlue} vs ${actual.intBlue}")
+ assertEquals(expected.intAlpha, actual.intAlpha, "alpha: ${expected.intAlpha} vs ${actual.intAlpha}")
+ }
+
+ // ---- Pack & unpack ----
+
+ @Test
+ fun packAndUnpack() {
+ val original = RgbColor.fromInt(red = 128, green = 64, blue = 32, alpha = 200)
+ val packed = original.toArgbInt()
+ val unpacked = packed.toRgbColor()
+ assertIntRgbEqual(original, unpacked)
+ }
+
+ @Test
+ fun packAndUnpackBlack() {
+ val rgb = RgbColor(0f, 0f, 0f, alpha = 1f)
+ val packed = rgb.toArgbInt()
+ val unpacked = packed.toRgbColor()
+ assertEquals(0, unpacked.intRed)
+ assertEquals(0, unpacked.intGreen)
+ assertEquals(0, unpacked.intBlue)
+ assertEquals(255, unpacked.intAlpha)
+ }
+
+ @Test
+ fun packAndUnpackWhite() {
+ val rgb = RgbColor(1f, 1f, 1f, alpha = 1f)
+ val packed = rgb.toArgbInt()
+ val unpacked = packed.toRgbColor()
+ assertEquals(255, unpacked.intRed)
+ assertEquals(255, unpacked.intGreen)
+ assertEquals(255, unpacked.intBlue)
+ assertEquals(255, unpacked.intAlpha)
+ }
+
+ @Test
+ fun packAndUnpackTransparent() {
+ val rgb = RgbColor(1f, 0f, 0f, alpha = 0f)
+ val packed = rgb.toArgbInt()
+ val unpacked = packed.toRgbColor()
+ assertEquals(255, unpacked.intRed)
+ assertEquals(0, unpacked.intGreen)
+ assertEquals(0, unpacked.intBlue)
+ assertEquals(0, unpacked.intAlpha)
+ }
+
+ // ---- argb() helper ----
+
+ @Test
+ fun opaqueRed() {
+ val packed = argb(255, 255, 0, 0)
+ assertEquals(0xFFFF0000.toInt(), packed)
+ }
+
+ @Test
+ fun opaqueGreen() {
+ val packed = argb(255, 0, 255, 0)
+ assertEquals(0xFF00FF00.toInt(), packed)
+ }
+
+ @Test
+ fun opaqueBlue() {
+ val packed = argb(255, 0, 0, 255)
+ assertEquals(0xFF0000FF.toInt(), packed)
+ }
+
+ @Test
+ fun transparentBlack() {
+ val packed = argb(0, 0, 0, 0)
+ assertEquals(0x00000000, packed)
+ }
+
+ @Test
+ fun argbClampsOutOfRangeComponents() {
+ // 256 clamps to 255, -1 clamps to 0 (instead of wrapping through the 0xFF mask)
+ val packed = argb(256, -1, 0, 0)
+ assertEquals(argb(255, 0, 0, 0), packed)
+ }
+
+ @Test
+ fun argbClampsLargeValues() {
+ val packed = argb(1000, 512, -300, 300)
+ assertEquals(argb(255, 255, 0, 255), packed)
+ }
+
+ // ---- setAlphaComponent ----
+
+ @Test
+ fun setAlphaOnOpaqueColor() {
+ val opaque = argb(255, 100, 150, 200)
+ val halfTransparent = setAlphaComponent(opaque, 128)
+ val rgb = halfTransparent.toRgbColor()
+ assertEquals(128, rgb.intAlpha)
+ assertEquals(100, rgb.intRed)
+ assertEquals(150, rgb.intGreen)
+ assertEquals(200, rgb.intBlue)
+ }
+
+ @Test
+ fun setAlphaToZero() {
+ val opaque = argb(255, 50, 100, 150)
+ val transparent = setAlphaComponent(opaque, 0)
+ val rgb = transparent.toRgbColor()
+ assertEquals(0, rgb.intAlpha)
+ assertEquals(50, rgb.intRed)
+ }
+
+ @Test
+ fun setAlphaToMax() {
+ val transparent = argb(0, 50, 100, 150)
+ val opaque = setAlphaComponent(transparent, 255)
+ val rgb = opaque.toRgbColor()
+ assertEquals(255, rgb.intAlpha)
+ }
+
+ @Test
+ fun setAlphaClampsOutOfRangeValues() {
+ val color = argb(255, 50, 100, 150)
+ assertEquals(255, setAlphaComponent(color, 300).toRgbColor().intAlpha)
+ assertEquals(0, setAlphaComponent(color, -5).toRgbColor().intAlpha)
+ }
+
+ // ---- blendArgb ----
+
+ @Test
+ fun blendBlackWhite() {
+ val black = argb(255, 0, 0, 0)
+ val white = argb(255, 255, 255, 255)
+ val mid = blendArgb(black, white, 0.5f)
+ val rgb = mid.toRgbColor()
+ // 127.5 rounds to 128 (blendArgb rounds instead of truncating)
+ assertEquals(128, rgb.intRed)
+ assertEquals(128, rgb.intGreen)
+ assertEquals(128, rgb.intBlue)
+ assertEquals(255, rgb.intAlpha)
+ }
+
+ // ---- Float -> Int -> Float precision ----
+
+ @Test
+ fun floatToIntRoundTrip() {
+ // Verify that packing to ARGB int and unpacking preserves 8-bit precision
+ val original = RgbColor(red = 0.5f, green = 0.25f, blue = 0.75f, alpha = 0.9f)
+ val packed = original.toArgbInt()
+ val unpacked = packed.toRgbColor()
+ // After quantization to 8-bit, values should be close
+ assertTrue(abs(original.red - unpacked.red) < 1f / 255f + 1e-5f, "red precision")
+ assertTrue(abs(original.green - unpacked.green) < 1f / 255f + 1e-5f, "green precision")
+ assertTrue(abs(original.blue - unpacked.blue) < 1f / 255f + 1e-5f, "blue precision")
+ assertTrue(abs(original.alpha - unpacked.alpha) < 1f / 255f + 1e-5f, "alpha precision")
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/BlendArgbTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/BlendArgbTest.kt
new file mode 100644
index 0000000..bfb258a
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/BlendArgbTest.kt
@@ -0,0 +1,107 @@
+package codes.side.colorpicker.conversion
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class BlendArgbTest {
+
+ private val black = argb(255, 0, 0, 0)
+ private val white = argb(255, 255, 255, 255)
+ private val red = argb(255, 255, 0, 0)
+ private val blue = argb(255, 0, 0, 255)
+
+ @Test
+ fun blendRatioZeroReturnsColor1() {
+ val result = blendArgb(black, white, 0f)
+ assertEquals(black, result)
+ }
+
+ @Test
+ fun blendRatioOneReturnsColor2() {
+ val result = blendArgb(black, white, 1f)
+ assertEquals(white, result)
+ }
+
+ @Test
+ fun blendHalfway() {
+ // 127.5 rounds to 128 (unlike AndroidX ColorUtils.blendARGB, which truncates to 127)
+ val result = blendArgb(black, white, 0.5f).toRgbColor()
+ assertEquals(128, result.intRed)
+ assertEquals(128, result.intGreen)
+ assertEquals(128, result.intBlue)
+ assertEquals(255, result.intAlpha)
+ }
+
+ @Test
+ fun blendWithAlpha() {
+ val transparent = argb(0, 255, 0, 0)
+ val opaque = argb(255, 255, 0, 0)
+ val result = blendArgb(transparent, opaque, 0.5f).toRgbColor()
+ assertEquals(128, result.intAlpha)
+ assertEquals(255, result.intRed)
+ }
+
+ @Test
+ fun blendQuarterWay() {
+ // 63.75 rounds to 64
+ val result = blendArgb(black, white, 0.25f).toRgbColor()
+ assertEquals(64, result.intRed)
+ assertEquals(64, result.intGreen)
+ assertEquals(64, result.intBlue)
+ }
+
+ @Test
+ fun blendDifferentColors() {
+ val result = blendArgb(red, blue, 0.5f).toRgbColor()
+ assertEquals(128, result.intRed)
+ assertEquals(0, result.intGreen)
+ assertEquals(128, result.intBlue)
+ }
+
+ @Test
+ fun blendThreeQuarterWay() {
+ // 191.25 rounds to 191
+ val result = blendArgb(black, white, 0.75f).toRgbColor()
+ assertEquals(191, result.intRed)
+ assertEquals(191, result.intGreen)
+ assertEquals(191, result.intBlue)
+ }
+
+ @Test
+ fun blendSameColor() {
+ val result = blendArgb(red, red, 0.5f)
+ assertEquals(red, result)
+ }
+
+ @Test
+ fun blendAlphaChannelOnly() {
+ val c1 = argb(0, 100, 100, 100)
+ val c2 = argb(255, 100, 100, 100)
+ val result = blendArgb(c1, c2, 0.5f).toRgbColor()
+ assertEquals(128, result.intAlpha)
+ assertEquals(100, result.intRed)
+ assertEquals(100, result.intGreen)
+ assertEquals(100, result.intBlue)
+ }
+
+ // ---- Ratio clamping & NaN ----
+
+ @Test
+ fun blendRatioAboveOneClampsToColor2() {
+ val result = blendArgb(black, white, 1.5f)
+ assertEquals(white, result)
+ }
+
+ @Test
+ fun blendRatioBelowZeroClampsToColor1() {
+ val result = blendArgb(black, white, -0.5f)
+ assertEquals(black, result)
+ }
+
+ @Test
+ fun blendNaNRatioReturnsColor1() {
+ // Documented behavior: NaN ratio is treated as 0
+ val result = blendArgb(red, blue, Float.NaN)
+ assertEquals(red, result)
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/ChainConversionsTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/ChainConversionsTest.kt
new file mode 100644
index 0000000..965bbd4
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/ChainConversionsTest.kt
@@ -0,0 +1,319 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ChainConversionsTest {
+
+ private val eps = 2e-5f
+ private val labEps = 0.5f // LAB uses transcendental functions, needs wider tolerance
+
+ private fun assertNear(expected: Float, actual: Float, tolerance: Float = eps, msg: String = "") {
+ assertTrue(
+ abs(expected - actual) <= tolerance,
+ "$msg expected=$expected actual=$actual diff=${abs(expected - actual)}"
+ )
+ }
+
+ // ===========================================================
+ // CMYK chain conversions
+ // ===========================================================
+
+ @Test
+ fun cmykToHsl() {
+ val cmyk = CmykColor(0f, 1f, 1f, 0f) // pure red in CMYK
+ val hsl = cmyk.toHsl()
+ assertNear(0f, hsl.hue, msg = "hue")
+ assertNear(1f, hsl.saturation, msg = "saturation")
+ assertNear(0.5f, hsl.lightness, msg = "lightness")
+ }
+
+ @Test
+ fun hslToCmyk() {
+ val hsl = HslColor(hue = 0f, saturation = 1f, lightness = 0.5f) // red
+ val cmyk = hsl.toCmyk()
+ assertNear(0f, cmyk.cyan, msg = "cyan")
+ assertNear(1f, cmyk.magenta, msg = "magenta")
+ assertNear(1f, cmyk.yellow, msg = "yellow")
+ assertNear(0f, cmyk.key, msg = "key")
+ }
+
+ @Test
+ fun cmykToArgbInt() {
+ val cmyk = CmykColor(0f, 0f, 0f, 0f) // white
+ val argb = cmyk.toArgbInt()
+ val rgb = argb.toRgbColor()
+ assertEquals(255, rgb.intRed)
+ assertEquals(255, rgb.intGreen)
+ assertEquals(255, rgb.intBlue)
+ }
+
+ @Test
+ fun intToCmykColor() {
+ val white = 0xFFFFFFFF.toInt()
+ val cmyk = white.toCmykColor()
+ assertNear(0f, cmyk.cyan)
+ assertNear(0f, cmyk.magenta)
+ assertNear(0f, cmyk.yellow)
+ assertNear(0f, cmyk.key)
+ }
+
+ // ===========================================================
+ // LAB chain conversions
+ // ===========================================================
+
+ @Test
+ fun labToHsl() {
+ val lab = LabColor(l = 100f, a = 0f, b = 0f) // white
+ val hsl = lab.toHsl()
+ assertNear(1f, hsl.lightness, tolerance = 0.01f, msg = "lightness")
+ }
+
+ @Test
+ fun hslToLab() {
+ val hsl = HslColor(hue = 0f, saturation = 0f, lightness = 0f) // black
+ val lab = hsl.toLab()
+ assertNear(0f, lab.l, tolerance = 0.5f, msg = "L")
+ }
+
+ @Test
+ fun labToArgbInt() {
+ val lab = LabColor(l = 0f, a = 0f, b = 0f) // black
+ val argb = lab.toArgbInt()
+ val rgb = argb.toRgbColor()
+ assertEquals(0, rgb.intRed)
+ assertEquals(0, rgb.intGreen)
+ assertEquals(0, rgb.intBlue)
+ }
+
+ @Test
+ fun intToLabColor() {
+ val black = 0xFF000000.toInt()
+ val lab = black.toLabColor()
+ assertNear(0f, lab.l, tolerance = 0.5f)
+ }
+
+ // ===========================================================
+ // CRITICAL: Round-trip precision tests (Float-based, no quantization)
+ // These prove the Float API eliminates the Int quantization bug.
+ // ===========================================================
+
+ // ---- HSL -> RGB -> HSL must be EXACT (within Float epsilon) ----
+
+ @Test
+ fun roundTripHslRgbHslExact_nonTrivialColor() {
+ val original = HslColor(hue = 210.5f, saturation = 0.47f, lightness = 0.63f)
+ val roundTripped = original.toRgb().toHsl()
+ assertNear(original.hue, roundTripped.hue, tolerance = eps, msg = "hue")
+ assertNear(original.saturation, roundTripped.saturation, tolerance = eps, msg = "saturation")
+ assertNear(original.lightness, roundTripped.lightness, tolerance = eps, msg = "lightness")
+ }
+
+ @Test
+ fun roundTripHslRgbHslExact_warmDesaturated() {
+ val original = HslColor(hue = 33.7f, saturation = 0.22f, lightness = 0.77f)
+ val roundTripped = original.toRgb().toHsl()
+ assertNear(original.hue, roundTripped.hue, tolerance = eps, msg = "hue")
+ assertNear(original.saturation, roundTripped.saturation, tolerance = eps, msg = "saturation")
+ assertNear(original.lightness, roundTripped.lightness, tolerance = eps, msg = "lightness")
+ }
+
+ @Test
+ fun roundTripHslRgbHslExact_deepPurple() {
+ val original = HslColor(hue = 275.3f, saturation = 0.85f, lightness = 0.31f)
+ val roundTripped = original.toRgb().toHsl()
+ assertNear(original.hue, roundTripped.hue, tolerance = eps, msg = "hue")
+ assertNear(original.saturation, roundTripped.saturation, tolerance = eps, msg = "saturation")
+ assertNear(original.lightness, roundTripped.lightness, tolerance = eps, msg = "lightness")
+ }
+
+ @Test
+ fun roundTripHslRgbHslExact_nearRed() {
+ val original = HslColor(hue = 5.5f, saturation = 0.91f, lightness = 0.44f)
+ val roundTripped = original.toRgb().toHsl()
+ assertNear(original.hue, roundTripped.hue, tolerance = eps, msg = "hue")
+ assertNear(original.saturation, roundTripped.saturation, tolerance = eps, msg = "saturation")
+ assertNear(original.lightness, roundTripped.lightness, tolerance = eps, msg = "lightness")
+ }
+
+ @Test
+ fun roundTripHslRgbHslExact_cyan() {
+ val original = HslColor(hue = 185.2f, saturation = 0.63f, lightness = 0.52f)
+ val roundTripped = original.toRgb().toHsl()
+ assertNear(original.hue, roundTripped.hue, tolerance = eps, msg = "hue")
+ assertNear(original.saturation, roundTripped.saturation, tolerance = eps, msg = "saturation")
+ assertNear(original.lightness, roundTripped.lightness, tolerance = eps, msg = "lightness")
+ }
+
+ // ---- HSL -> RGB -> CMYK -> RGB -> HSL must be EXACT ----
+
+ @Test
+ fun roundTripHslRgbCmykRgbHslExact() {
+ val original = HslColor(hue = 210.5f, saturation = 0.47f, lightness = 0.63f)
+ val result = original.toRgb().toCmyk().toRgb().toHsl()
+ assertNear(original.hue, result.hue, tolerance = eps, msg = "hue")
+ assertNear(original.saturation, result.saturation, tolerance = eps, msg = "saturation")
+ assertNear(original.lightness, result.lightness, tolerance = eps, msg = "lightness")
+ }
+
+ @Test
+ fun roundTripHslRgbCmykRgbHslExact_green() {
+ val original = HslColor(hue = 120f, saturation = 1f, lightness = 0.5f)
+ val result = original.toRgb().toCmyk().toRgb().toHsl()
+ assertNear(original.hue, result.hue, tolerance = eps, msg = "hue")
+ assertNear(original.saturation, result.saturation, tolerance = eps, msg = "saturation")
+ assertNear(original.lightness, result.lightness, tolerance = eps, msg = "lightness")
+ }
+
+ @Test
+ fun roundTripHslRgbCmykRgbHslExact_nonTrivial2() {
+ val original = HslColor(hue = 97.3f, saturation = 0.38f, lightness = 0.71f)
+ val result = original.toRgb().toCmyk().toRgb().toHsl()
+ assertNear(original.hue, result.hue, tolerance = eps, msg = "hue")
+ assertNear(original.saturation, result.saturation, tolerance = eps, msg = "saturation")
+ assertNear(original.lightness, result.lightness, tolerance = eps, msg = "lightness")
+ }
+
+ // ---- HSL -> RGB -> LAB -> RGB -> HSL must be near-exact (LAB uses transcendental) ----
+
+ @Test
+ fun roundTripHslRgbLabRgbHslNearExact() {
+ val original = HslColor(hue = 210.5f, saturation = 0.47f, lightness = 0.63f)
+ val result = original.toRgb().toLab().toRgb().toHsl()
+ assertNear(original.hue, result.hue, tolerance = labEps, msg = "hue")
+ assertNear(original.saturation, result.saturation, tolerance = 0.01f, msg = "saturation")
+ assertNear(original.lightness, result.lightness, tolerance = 0.01f, msg = "lightness")
+ }
+
+ @Test
+ fun roundTripHslRgbLabRgbHslNearExact_warmDesaturated() {
+ val original = HslColor(hue = 33.7f, saturation = 0.22f, lightness = 0.77f)
+ val result = original.toRgb().toLab().toRgb().toHsl()
+ assertNear(original.hue, result.hue, tolerance = labEps, msg = "hue")
+ assertNear(original.saturation, result.saturation, tolerance = 0.01f, msg = "saturation")
+ assertNear(original.lightness, result.lightness, tolerance = 0.01f, msg = "lightness")
+ }
+
+ @Test
+ fun roundTripHslRgbLabRgbHslNearExact_deepPurple() {
+ val original = HslColor(hue = 275.3f, saturation = 0.85f, lightness = 0.31f)
+ val result = original.toRgb().toLab().toRgb().toHsl()
+ assertNear(original.hue, result.hue, tolerance = labEps, msg = "hue")
+ assertNear(original.saturation, result.saturation, tolerance = 0.01f, msg = "saturation")
+ assertNear(original.lightness, result.lightness, tolerance = 0.01f, msg = "lightness")
+ }
+
+ // ===========================================================
+ // Cross-space round trips (RGB through all spaces)
+ // ===========================================================
+
+ @Test
+ fun rgbThroughAllSpaces() {
+ val original = RgbColor(0.392f, 0.588f, 0.784f)
+ val hsl = original.toHsl()
+ val cmyk = original.toCmyk()
+ val lab = original.toLab()
+
+ val fromHsl = hsl.toRgb()
+ val fromCmyk = cmyk.toRgb()
+ val fromLab = lab.toRgb()
+
+ // HSL round-trip: exact within Float epsilon
+ assertNear(original.red, fromHsl.red, tolerance = eps, msg = "HSL red")
+ assertNear(original.green, fromHsl.green, tolerance = eps, msg = "HSL green")
+ assertNear(original.blue, fromHsl.blue, tolerance = eps, msg = "HSL blue")
+
+ // CMYK round-trip: exact within Float epsilon
+ assertNear(original.red, fromCmyk.red, tolerance = eps, msg = "CMYK red")
+ assertNear(original.green, fromCmyk.green, tolerance = eps, msg = "CMYK green")
+ assertNear(original.blue, fromCmyk.blue, tolerance = eps, msg = "CMYK blue")
+
+ // LAB round-trip: near-exact (transcendental functions)
+ assertNear(original.red, fromLab.red, tolerance = 0.01f, msg = "LAB red")
+ assertNear(original.green, fromLab.green, tolerance = 0.01f, msg = "LAB green")
+ assertNear(original.blue, fromLab.blue, tolerance = 0.01f, msg = "LAB blue")
+ }
+
+ @Test
+ fun rgbThroughAllSpaces_brightOrange() {
+ val original = RgbColor(0.95f, 0.55f, 0.1f)
+ val fromHsl = original.toHsl().toRgb()
+ val fromCmyk = original.toCmyk().toRgb()
+ val fromLab = original.toLab().toRgb()
+
+ assertNear(original.red, fromHsl.red, tolerance = eps, msg = "HSL red")
+ assertNear(original.green, fromHsl.green, tolerance = eps, msg = "HSL green")
+ assertNear(original.blue, fromHsl.blue, tolerance = eps, msg = "HSL blue")
+
+ assertNear(original.red, fromCmyk.red, tolerance = eps, msg = "CMYK red")
+ assertNear(original.green, fromCmyk.green, tolerance = eps, msg = "CMYK green")
+ assertNear(original.blue, fromCmyk.blue, tolerance = eps, msg = "CMYK blue")
+
+ assertNear(original.red, fromLab.red, tolerance = 0.01f, msg = "LAB red")
+ assertNear(original.green, fromLab.green, tolerance = 0.01f, msg = "LAB green")
+ assertNear(original.blue, fromLab.blue, tolerance = 0.01f, msg = "LAB blue")
+ }
+
+ // ===========================================================
+ // Alpha preservation across chains
+ // ===========================================================
+
+ @Test
+ fun alphaPreservedThroughCmykChain() {
+ val hsl = HslColor(hue = 60f, saturation = 0.8f, lightness = 0.4f, alpha = 0.392f)
+ val result = hsl.toCmyk().toRgb().toHsl()
+ assertEquals(0.392f, result.alpha)
+ }
+
+ @Test
+ fun alphaPreservedThroughLabChain() {
+ val hsl = HslColor(hue = 60f, saturation = 0.8f, lightness = 0.4f, alpha = 0.392f)
+ val result = hsl.toLab().toRgb().toHsl()
+ assertEquals(0.392f, result.alpha)
+ }
+
+ @Test
+ fun alphaPreservedThroughFullChain() {
+ val original = HslColor(hue = 200f, saturation = 0.6f, lightness = 0.5f, alpha = 0.333f)
+ val rgb = original.toRgb()
+ assertEquals(0.333f, rgb.alpha)
+ val cmyk = rgb.toCmyk()
+ assertEquals(0.333f, cmyk.alpha)
+ val lab = rgb.toLab()
+ assertEquals(0.333f, lab.alpha)
+ val backFromCmyk = cmyk.toRgb()
+ assertEquals(0.333f, backFromCmyk.alpha)
+ val backFromLab = lab.toRgb()
+ assertEquals(0.333f, backFromLab.alpha)
+ }
+
+ // ===========================================================
+ // Achromatic edge cases
+ // ===========================================================
+
+ @Test
+ fun achromaticBlackThroughAllSpaces() {
+ val hsl = HslColor(hue = 0f, saturation = 0f, lightness = 0f)
+ val cmyk = hsl.toCmyk()
+ assertEquals(1f, cmyk.key)
+ val lab = hsl.toLab()
+ assertNear(0f, lab.l, tolerance = 0.5f, msg = "Lab L for black")
+ val backHsl = cmyk.toHsl()
+ assertNear(0f, backHsl.lightness, tolerance = eps, msg = "lightness for black via CMYK")
+ }
+
+ @Test
+ fun achromaticWhiteThroughAllSpaces() {
+ val hsl = HslColor(hue = 0f, saturation = 0f, lightness = 1f)
+ val cmyk = hsl.toCmyk()
+ assertNear(0f, cmyk.key, msg = "key for white")
+ val lab = hsl.toLab()
+ assertNear(100f, lab.l, tolerance = 0.5f, msg = "Lab L for white")
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/CmykConversionsTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/CmykConversionsTest.kt
new file mode 100644
index 0000000..24b5795
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/CmykConversionsTest.kt
@@ -0,0 +1,166 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class CmykConversionsTest {
+
+ private val eps = 1e-5f
+
+ private fun assertNear(expected: Float, actual: Float, tolerance: Float = eps, msg: String = "") {
+ assertTrue(abs(expected - actual) <= tolerance, "$msg expected=$expected actual=$actual diff=${abs(expected - actual)}")
+ }
+
+ // ---- CMYK -> RGB known colors ----
+
+ @Test
+ fun blackCmykToRgb() {
+ val cmyk = CmykColor(0f, 0f, 0f, 1f)
+ val rgb = cmyk.toRgb()
+ assertNear(0f, rgb.red)
+ assertNear(0f, rgb.green)
+ assertNear(0f, rgb.blue)
+ }
+
+ @Test
+ fun whiteCmykToRgb() {
+ val cmyk = CmykColor(0f, 0f, 0f, 0f)
+ val rgb = cmyk.toRgb()
+ assertNear(1f, rgb.red)
+ assertNear(1f, rgb.green)
+ assertNear(1f, rgb.blue)
+ }
+
+ @Test
+ fun pureCyanToRgb() {
+ val cmyk = CmykColor(1f, 0f, 0f, 0f)
+ val rgb = cmyk.toRgb()
+ assertNear(0f, rgb.red)
+ assertNear(1f, rgb.green)
+ assertNear(1f, rgb.blue)
+ }
+
+ @Test
+ fun pureMagentaToRgb() {
+ val cmyk = CmykColor(0f, 1f, 0f, 0f)
+ val rgb = cmyk.toRgb()
+ assertNear(1f, rgb.red)
+ assertNear(0f, rgb.green)
+ assertNear(1f, rgb.blue)
+ }
+
+ @Test
+ fun pureYellowToRgb() {
+ val cmyk = CmykColor(0f, 0f, 1f, 0f)
+ val rgb = cmyk.toRgb()
+ assertNear(1f, rgb.red)
+ assertNear(1f, rgb.green)
+ assertNear(0f, rgb.blue)
+ }
+
+ @Test
+ fun cmykToRgbKnownValue() {
+ // CMYK(0.30, 0.60, 0.10, 0.20)
+ // R = (1-0.30)*(1-0.20) = 0.70*0.80 = 0.56
+ // G = (1-0.60)*(1-0.20) = 0.40*0.80 = 0.32
+ // B = (1-0.10)*(1-0.20) = 0.90*0.80 = 0.72
+ val cmyk = CmykColor(cyan = 0.3f, magenta = 0.6f, yellow = 0.1f, key = 0.2f)
+ val rgb = cmyk.toRgb()
+ assertNear(0.56f, rgb.red, msg = "red")
+ assertNear(0.32f, rgb.green, msg = "green")
+ assertNear(0.72f, rgb.blue, msg = "blue")
+ }
+
+ // ---- RGB -> CMYK ----
+
+ @Test
+ fun rgbBlackToCmyk() {
+ val rgb = RgbColor(0f, 0f, 0f)
+ val cmyk = rgb.toCmyk()
+ assertEquals(1f, cmyk.key)
+ }
+
+ @Test
+ fun rgbWhiteToCmyk() {
+ val rgb = RgbColor(1f, 1f, 1f)
+ val cmyk = rgb.toCmyk()
+ assertNear(0f, cmyk.cyan)
+ assertNear(0f, cmyk.magenta)
+ assertNear(0f, cmyk.yellow)
+ assertNear(0f, cmyk.key)
+ }
+
+ @Test
+ fun rgbRedToCmyk() {
+ // Pure red -> C=0, M=1, Y=1, K=0
+ val rgb = RgbColor(1f, 0f, 0f)
+ val cmyk = rgb.toCmyk()
+ assertNear(0f, cmyk.cyan)
+ assertNear(1f, cmyk.magenta)
+ assertNear(1f, cmyk.yellow)
+ assertNear(0f, cmyk.key)
+ }
+
+ @Test
+ fun rgbGreenToCmyk() {
+ val rgb = RgbColor(0f, 1f, 0f)
+ val cmyk = rgb.toCmyk()
+ assertNear(1f, cmyk.cyan)
+ assertNear(0f, cmyk.magenta)
+ assertNear(1f, cmyk.yellow)
+ assertNear(0f, cmyk.key)
+ }
+
+ @Test
+ fun rgbBlueToCmyk() {
+ val rgb = RgbColor(0f, 0f, 1f)
+ val cmyk = rgb.toCmyk()
+ assertNear(1f, cmyk.cyan)
+ assertNear(1f, cmyk.magenta)
+ assertNear(0f, cmyk.yellow)
+ assertNear(0f, cmyk.key)
+ }
+
+ // ---- Round-trip ----
+
+ @Test
+ fun cmykRoundTripPreservesVisualColor() {
+ // CMYK -> RGB -> CMYK does NOT preserve CMYK components because key is
+ // recomputed from max(r,g,b). But the visual result (RGB) must be identical.
+ val original = CmykColor(cyan = 0.3f, magenta = 0.6f, yellow = 0.1f, key = 0.2f)
+ val rgb1 = original.toRgb()
+ val rgb2 = original.toRgb().toCmyk().toRgb()
+ assertNear(rgb1.red, rgb2.red, msg = "red")
+ assertNear(rgb1.green, rgb2.green, msg = "green")
+ assertNear(rgb1.blue, rgb2.blue, msg = "blue")
+ }
+
+ @Test
+ fun rgbToCmykAndBack() {
+ val original = RgbColor(0.4f, 0.6f, 0.8f)
+ val roundTripped = original.toCmyk().toRgb()
+ assertNear(original.red, roundTripped.red, msg = "red")
+ assertNear(original.green, roundTripped.green, msg = "green")
+ assertNear(original.blue, roundTripped.blue, msg = "blue")
+ }
+
+ // ---- Alpha preserved ----
+
+ @Test
+ fun alphaPreservedCmykToRgb() {
+ val cmyk = CmykColor(0.5f, 0.5f, 0.5f, 0.5f, alpha = 0.392f)
+ val rgb = cmyk.toRgb()
+ assertEquals(0.392f, rgb.alpha)
+ }
+
+ @Test
+ fun alphaPreservedRgbToCmyk() {
+ val rgb = RgbColor(0.5f, 0.5f, 0.5f, alpha = 0.67f)
+ val cmyk = rgb.toCmyk()
+ assertEquals(0.67f, cmyk.alpha)
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/ComposeColorExtTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/ComposeColorExtTest.kt
new file mode 100644
index 0000000..511dad6
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/ComposeColorExtTest.kt
@@ -0,0 +1,108 @@
+package codes.side.colorpicker.conversion
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.colorspace.ColorSpaces
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+class ComposeColorExtTest {
+
+ private fun assertNear(expected: Float, actual: Float, tolerance: Float, msg: String = "") {
+ assertTrue(abs(expected - actual) <= tolerance, "$msg expected=$expected actual=$actual diff=${abs(expected - actual)}")
+ }
+
+ // ---- sRGB round-trips ----
+
+ @Test
+ fun srgbRoundTripIsExact() {
+ val original = Color(red = 0.25f, green = 0.5f, blue = 0.75f, alpha = 0.9f)
+ val roundTripped = original.toRgbColor().toComposeColor()
+ assertEquals(original, roundTripped)
+ }
+
+ @Test
+ fun srgbRoundTripBlackAndWhite() {
+ assertEquals(Color.Black, Color.Black.toRgbColor().toComposeColor())
+ assertEquals(Color.White, Color.White.toRgbColor().toComposeColor())
+ }
+
+ @Test
+ fun srgbColorChannelsAreReadDirectly() {
+ val rgb = Color.Red.toRgbColor()
+ assertEquals(RgbColor(red = 1f, green = 0f, blue = 0f, alpha = 1f), rgb)
+ }
+
+ // ---- Wide gamut ----
+
+ @Test
+ fun displayP3WhiteConvertsToSrgbWhite() {
+ val p3White = Color(red = 1f, green = 1f, blue = 1f, colorSpace = ColorSpaces.DisplayP3)
+ val rgb = p3White.toRgbColor()
+ assertNear(1f, rgb.red, tolerance = 0.005f, msg = "red")
+ assertNear(1f, rgb.green, tolerance = 0.005f, msg = "green")
+ assertNear(1f, rgb.blue, tolerance = 0.005f, msg = "blue")
+ assertEquals(1f, rgb.alpha)
+ }
+
+ @Test
+ fun displayP3ColorIsConvertedToSrgbBeforeReadingChannels() {
+ val p3 = Color(red = 0.5f, green = 0.25f, blue = 0.75f, colorSpace = ColorSpaces.DisplayP3)
+ val rgb = p3.toRgbColor()
+ // The raw P3 channel values must have gone through a real sRGB conversion
+ assertTrue(abs(rgb.red - 0.5f) > 0.001f, "red should differ from raw P3 value: ${rgb.red}")
+ assertTrue(rgb.blue > rgb.green, "hue ordering preserved: blue=${rgb.blue} green=${rgb.green}")
+ assertEquals(1f, rgb.alpha)
+ }
+
+ // ---- Unspecified ----
+
+ @Test
+ fun unspecifiedColorThrows() {
+ assertFailsWith { Color.Unspecified.toRgbColor() }
+ }
+
+ @Test
+ fun unspecifiedColorThrowsForModelConversions() {
+ assertFailsWith { Color.Unspecified.toHslColor() }
+ assertFailsWith { Color.Unspecified.toCmykColor() }
+ assertFailsWith { Color.Unspecified.toLabColor() }
+ }
+
+ // ---- Model conversion symmetry ----
+
+ @Test
+ fun composeColorToHslColor() {
+ assertEquals(HslColor(hue = 0f, saturation = 1f, lightness = 0.5f), Color.Red.toHslColor())
+ }
+
+ @Test
+ fun composeColorToCmykColor() {
+ val cmyk = Color.Black.toCmykColor()
+ assertEquals(1f, cmyk.key)
+ assertEquals(0f, cmyk.cyan)
+ assertEquals(0f, cmyk.magenta)
+ assertEquals(0f, cmyk.yellow)
+ }
+
+ @Test
+ fun composeColorToLabColor() {
+ val lab = Color.White.toLabColor()
+ assertNear(100f, lab.l, tolerance = 0.01f, msg = "L")
+ assertNear(0f, lab.a, tolerance = 0.01f, msg = "a")
+ assertNear(0f, lab.b, tolerance = 0.01f, msg = "b")
+ }
+
+ @Test
+ fun alphaPropagatesThroughModelConversions() {
+ val color = Color(red = 0f, green = 0f, blue = 1f, alpha = 0.5f)
+ // Compose quantizes sRGB alpha to 8 bits, so allow one quantization step
+ assertNear(0.5f, color.toHslColor().alpha, tolerance = 1f / 255f, msg = "hsl alpha")
+ assertNear(0.5f, color.toCmykColor().alpha, tolerance = 1f / 255f, msg = "cmyk alpha")
+ assertNear(0.5f, color.toLabColor().alpha, tolerance = 1f / 255f, msg = "lab alpha")
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/HexConversionsTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/HexConversionsTest.kt
new file mode 100644
index 0000000..b27e1c6
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/HexConversionsTest.kt
@@ -0,0 +1,155 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class HexConversionsTest {
+
+ // ---- PickerColor.toHexString ----
+
+ @Test
+ fun rgbToHexStringWithAlpha() {
+ assertEquals("#FFFF0000", RgbColor.Red.toHexString())
+ }
+
+ @Test
+ fun rgbToHexStringWithoutAlpha() {
+ assertEquals("#FF0000", RgbColor.Red.toHexString(includeAlpha = false))
+ }
+
+ @Test
+ fun rgbToHexStringWithTranslucentAlpha() {
+ val color = RgbColor.fromInt(red = 255, green = 128, blue = 64, alpha = 128)
+ assertEquals("#80FF8040", color.toHexString())
+ assertEquals("#FF8040", color.toHexString(includeAlpha = false))
+ }
+
+ @Test
+ fun hexStringPadsLeadingZeros() {
+ val color = RgbColor.fromInt(red = 0, green = 0, blue = 18, alpha = 0)
+ assertEquals("#00000012", color.toHexString())
+ assertEquals("#000012", color.toHexString(includeAlpha = false))
+ }
+
+ @Test
+ fun hslToHexString() {
+ assertEquals("#FFFF0000", HslColor.Red.toHexString())
+ }
+
+ @Test
+ fun cmykToHexString() {
+ assertEquals("#FFFFFFFF", CmykColor.White.toHexString())
+ }
+
+ @Test
+ fun labToHexString() {
+ assertEquals("#FF000000", LabColor.Black.toHexString())
+ }
+
+ // ---- Int.toHexColorString ----
+
+ @Test
+ fun intToHexColorString() {
+ assertEquals("#80FF8040", 0x80FF8040.toInt().toHexColorString())
+ assertEquals("#FF8040", 0x80FF8040.toInt().toHexColorString(includeAlpha = false))
+ assertEquals("#00000000", 0x00000000.toHexColorString())
+ assertEquals("#FFFFFFFF", 0xFFFFFFFF.toInt().toHexColorString())
+ }
+
+ // ---- String.toRgbColorOrNull ----
+
+ @Test
+ fun parseSixDigitWithHash() {
+ assertEquals(RgbColor.Red, "#FF0000".toRgbColorOrNull())
+ }
+
+ @Test
+ fun parseSixDigitWithoutHash() {
+ assertEquals(RgbColor.Red, "FF0000".toRgbColorOrNull())
+ }
+
+ @Test
+ fun parseIsCaseInsensitive() {
+ assertEquals(RgbColor.Red, "#ff0000".toRgbColorOrNull())
+ assertEquals("#fF0000".toRgbColorOrNull(), "#Ff0000".toRgbColorOrNull())
+ }
+
+ @Test
+ fun parseSixDigitDefaultsAlphaToOpaque() {
+ val color = "#336699".toRgbColorOrNull()!!
+ assertEquals(1f, color.alpha)
+ assertEquals(RgbColor.fromInt(red = 0x33, green = 0x66, blue = 0x99), color)
+ }
+
+ @Test
+ fun parseShorthandExpandsDigits() {
+ // #ABC expands to #AABBCC with opaque alpha
+ val color = "#abc".toRgbColorOrNull()!!
+ assertEquals(RgbColor.fromInt(red = 0xAA, green = 0xBB, blue = 0xCC), color)
+ assertEquals(1f, color.alpha)
+ }
+
+ @Test
+ fun parseShorthandWithoutHash() {
+ assertEquals("#F00".toRgbColorOrNull(), "F00".toRgbColorOrNull())
+ assertEquals(RgbColor.Red, "F00".toRgbColorOrNull())
+ }
+
+ @Test
+ fun parseEightDigitReadsAlphaFirst() {
+ val color = "#80FF0000".toRgbColorOrNull()!!
+ assertEquals(128, color.intAlpha)
+ assertEquals(255, color.intRed)
+ assertEquals(0, color.intGreen)
+ assertEquals(0, color.intBlue)
+ }
+
+ @Test
+ fun parseInvalidInputsReturnNull() {
+ assertNull("".toRgbColorOrNull())
+ assertNull("#".toRgbColorOrNull())
+ assertNull("#12".toRgbColorOrNull())
+ assertNull("#12345".toRgbColorOrNull())
+ assertNull("#1234567".toRgbColorOrNull())
+ assertNull("#123456789".toRgbColorOrNull())
+ assertNull("#GGHHII".toRgbColorOrNull())
+ assertNull("#-1FF000".toRgbColorOrNull())
+ assertNull("not a color".toRgbColorOrNull())
+ assertNull("##FF0000".toRgbColorOrNull())
+ }
+
+ // ---- String.toRgbColor ----
+
+ @Test
+ fun strictParseReturnsColor() {
+ assertEquals(RgbColor.Red, "#FF0000".toRgbColor())
+ }
+
+ @Test
+ fun strictParseThrowsWithOffendingString() {
+ val exception = assertFailsWith { "nope".toRgbColor() }
+ assertTrue(exception.message!!.contains("nope"), "message should contain the input: ${exception.message}")
+ }
+
+ // ---- Round-trips ----
+
+ @Test
+ fun hexToColorToHexRoundTrip() {
+ assertEquals("#80FF8040", "#80FF8040".toRgbColor().toHexString())
+ assertEquals("#FF336699", "#336699".toRgbColor().toHexString())
+ assertEquals("#336699", "#336699".toRgbColor().toHexString(includeAlpha = false))
+ }
+
+ @Test
+ fun colorToHexToColorRoundTrip() {
+ val original = RgbColor.fromInt(red = 12, green = 200, blue = 99, alpha = 42)
+ assertEquals(original, original.toHexString().toRgbColor())
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/HslConversionsTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/HslConversionsTest.kt
new file mode 100644
index 0000000..8d8df74
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/HslConversionsTest.kt
@@ -0,0 +1,236 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class HslConversionsTest {
+
+ private val eps = 5e-5f
+
+ private fun assertNear(expected: Float, actual: Float, tolerance: Float = eps, msg: String = "") {
+ assertTrue(abs(expected - actual) <= tolerance, "$msg expected=$expected actual=$actual diff=${abs(expected - actual)}")
+ }
+
+ // ---- HSL -> RGB primary colors ----
+
+ @Test
+ fun redHslToRgb() {
+ val hsl = HslColor(hue = 0f, saturation = 1f, lightness = 0.5f)
+ val rgb = hsl.toRgb()
+ assertNear(1f, rgb.red, msg = "red")
+ assertNear(0f, rgb.green, msg = "green")
+ assertNear(0f, rgb.blue, msg = "blue")
+ }
+
+ @Test
+ fun hue360HslToRgbIsPureRed() {
+ // Hue 360 is normalized to 0 by the model, so it converts exactly like pure red
+ val hsl = HslColor(hue = 360f, saturation = 1f, lightness = 0.5f)
+ val rgb = hsl.toRgb()
+ assertNear(1f, rgb.red, msg = "red")
+ assertNear(0f, rgb.green, msg = "green")
+ assertNear(0f, rgb.blue, msg = "blue")
+ }
+
+ @Test
+ fun greenHslToRgb() {
+ val hsl = HslColor(hue = 120f, saturation = 1f, lightness = 0.5f)
+ val rgb = hsl.toRgb()
+ assertNear(0f, rgb.red, msg = "red")
+ assertNear(1f, rgb.green, msg = "green")
+ assertNear(0f, rgb.blue, msg = "blue")
+ }
+
+ @Test
+ fun blueHslToRgb() {
+ val hsl = HslColor(hue = 240f, saturation = 1f, lightness = 0.5f)
+ val rgb = hsl.toRgb()
+ assertNear(0f, rgb.red, msg = "red")
+ assertNear(0f, rgb.green, msg = "green")
+ assertNear(1f, rgb.blue, msg = "blue")
+ }
+
+ @Test
+ fun yellowHslToRgb() {
+ val hsl = HslColor(hue = 60f, saturation = 1f, lightness = 0.5f)
+ val rgb = hsl.toRgb()
+ assertNear(1f, rgb.red, msg = "red")
+ assertNear(1f, rgb.green, msg = "green")
+ assertNear(0f, rgb.blue, msg = "blue")
+ }
+
+ @Test
+ fun cyanHslToRgb() {
+ val hsl = HslColor(hue = 180f, saturation = 1f, lightness = 0.5f)
+ val rgb = hsl.toRgb()
+ assertNear(0f, rgb.red, msg = "red")
+ assertNear(1f, rgb.green, msg = "green")
+ assertNear(1f, rgb.blue, msg = "blue")
+ }
+
+ @Test
+ fun magentaHslToRgb() {
+ val hsl = HslColor(hue = 300f, saturation = 1f, lightness = 0.5f)
+ val rgb = hsl.toRgb()
+ assertNear(1f, rgb.red, msg = "red")
+ assertNear(0f, rgb.green, msg = "green")
+ assertNear(1f, rgb.blue, msg = "blue")
+ }
+
+ // ---- HSL -> RGB achromatic ----
+
+ @Test
+ fun whiteHslToRgb() {
+ val hsl = HslColor(hue = 0f, saturation = 0f, lightness = 1f)
+ val rgb = hsl.toRgb()
+ assertNear(1f, rgb.red)
+ assertNear(1f, rgb.green)
+ assertNear(1f, rgb.blue)
+ }
+
+ @Test
+ fun blackHslToRgb() {
+ val hsl = HslColor(hue = 0f, saturation = 0f, lightness = 0f)
+ val rgb = hsl.toRgb()
+ assertNear(0f, rgb.red)
+ assertNear(0f, rgb.green)
+ assertNear(0f, rgb.blue)
+ }
+
+ @Test
+ fun midGrayHslToRgb() {
+ val hsl = HslColor(hue = 0f, saturation = 0f, lightness = 0.5f)
+ val rgb = hsl.toRgb()
+ assertNear(0.5f, rgb.red)
+ assertNear(0.5f, rgb.green)
+ assertNear(0.5f, rgb.blue)
+ }
+
+ // ---- RGB -> HSL ----
+
+ @Test
+ fun rgbRedToHsl() {
+ val rgb = RgbColor(1f, 0f, 0f)
+ val hsl = rgb.toHsl()
+ assertNear(0f, hsl.hue)
+ assertNear(1f, hsl.saturation)
+ assertNear(0.5f, hsl.lightness)
+ }
+
+ @Test
+ fun rgbWhiteToHsl() {
+ val rgb = RgbColor(1f, 1f, 1f)
+ val hsl = rgb.toHsl()
+ assertNear(0f, hsl.saturation)
+ assertNear(1f, hsl.lightness)
+ }
+
+ @Test
+ fun rgbBlackToHsl() {
+ val rgb = RgbColor(0f, 0f, 0f)
+ val hsl = rgb.toHsl()
+ assertNear(0f, hsl.saturation)
+ assertNear(0f, hsl.lightness)
+ }
+
+ @Test
+ fun rgbGreenToHsl() {
+ val rgb = RgbColor(0f, 1f, 0f)
+ val hsl = rgb.toHsl()
+ assertNear(120f, hsl.hue)
+ assertNear(1f, hsl.saturation)
+ assertNear(0.5f, hsl.lightness)
+ }
+
+ @Test
+ fun rgbBlueToHsl() {
+ val rgb = RgbColor(0f, 0f, 1f)
+ val hsl = rgb.toHsl()
+ assertNear(240f, hsl.hue)
+ assertNear(1f, hsl.saturation)
+ assertNear(0.5f, hsl.lightness)
+ }
+
+ // ---- Non-trivial color round-trip ----
+
+ @Test
+ fun roundTripHslNonTrivial() {
+ val original = HslColor(hue = 210.5f, saturation = 0.47f, lightness = 0.63f)
+ val roundTripped = original.toRgb().toHsl()
+ assertNear(original.hue, roundTripped.hue, msg = "hue")
+ assertNear(original.saturation, roundTripped.saturation, msg = "saturation")
+ assertNear(original.lightness, roundTripped.lightness, msg = "lightness")
+ }
+
+ @Test
+ fun roundTripHslDesaturated() {
+ val original = HslColor(hue = 45f, saturation = 0.15f, lightness = 0.82f)
+ val roundTripped = original.toRgb().toHsl()
+ assertNear(original.hue, roundTripped.hue, msg = "hue")
+ assertNear(original.saturation, roundTripped.saturation, msg = "saturation")
+ assertNear(original.lightness, roundTripped.lightness, msg = "lightness")
+ }
+
+ @Test
+ fun roundTripHslHighSaturation() {
+ val original = HslColor(hue = 333.3f, saturation = 0.92f, lightness = 0.37f)
+ val roundTripped = original.toRgb().toHsl()
+ assertNear(original.hue, roundTripped.hue, msg = "hue")
+ assertNear(original.saturation, roundTripped.saturation, msg = "saturation")
+ assertNear(original.lightness, roundTripped.lightness, msg = "lightness")
+ }
+
+ // ---- Alpha preserved ----
+
+ @Test
+ fun alphaPreservedHslToRgb() {
+ val hsl = HslColor(hue = 0f, saturation = 1f, lightness = 0.5f, alpha = 0.502f)
+ val rgb = hsl.toRgb()
+ assertEquals(0.502f, rgb.alpha)
+ }
+
+ @Test
+ fun alphaPreservedRgbToHsl() {
+ val rgb = RgbColor(0.5f, 0.3f, 0.1f, alpha = 0.75f)
+ val hsl = rgb.toHsl()
+ assertEquals(0.75f, hsl.alpha)
+ }
+
+ // ---- ARGB int conversions ----
+
+ @Test
+ fun argbIntRoundTrip() {
+ val hsl = HslColor(hue = 180f, saturation = 0.5f, lightness = 0.4f, alpha = 200f / 255f)
+ val restored = hsl.toArgbInt().toHslColor()
+ // ARGB int round-trip quantizes to 8-bit, so allow +-1 int unit tolerance
+ assertTrue(abs(hsl.intHue - restored.intHue) <= 1, "hue: ${hsl.intHue} vs ${restored.intHue}")
+ assertTrue(abs(hsl.intSaturation - restored.intSaturation) <= 1, "sat: ${hsl.intSaturation} vs ${restored.intSaturation}")
+ assertTrue(abs(hsl.intLightness - restored.intLightness) <= 1, "light: ${hsl.intLightness} vs ${restored.intLightness}")
+ assertTrue(abs(hsl.intAlpha - restored.intAlpha) <= 1, "alpha: ${hsl.intAlpha} vs ${restored.intAlpha}")
+ }
+
+ @Test
+ fun intToHslColor() {
+ val argb = 0xFFFF0000.toInt() // opaque red
+ val hsl = argb.toHslColor()
+ assertNear(0f, hsl.hue)
+ assertNear(1f, hsl.saturation)
+ assertNear(0.5f, hsl.lightness)
+ assertNear(1f, hsl.alpha)
+ }
+
+ // ---- Hue segment 5 (300-360) ----
+
+ @Test
+ fun hslHueSegment5() {
+ val hsl = HslColor(hue = 330f, saturation = 1f, lightness = 0.5f)
+ val rgb = hsl.toRgb()
+ assertNear(1f, rgb.red, msg = "red")
+ assertNear(0f, rgb.green, msg = "green")
+ assertNear(0.5f, rgb.blue, msg = "blue")
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/LabConversionsTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/LabConversionsTest.kt
new file mode 100644
index 0000000..e46ac91
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/LabConversionsTest.kt
@@ -0,0 +1,162 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class LabConversionsTest {
+
+ private val eps = 1e-5f
+ // LAB uses transcendental functions so needs a slightly wider tolerance
+ private val labEps = 0.5f
+
+ private fun assertNear(expected: Float, actual: Float, tolerance: Float = eps, msg: String = "") {
+ assertTrue(abs(expected - actual) <= tolerance, "$msg expected=$expected actual=$actual diff=${abs(expected - actual)}")
+ }
+
+ // ---- LAB -> RGB known colors ----
+
+ @Test
+ fun whiteLabToRgb() {
+ val lab = LabColor(l = 100f, a = 0f, b = 0f)
+ val rgb = lab.toRgb()
+ assertNear(1f, rgb.red, tolerance = 0.01f, msg = "red")
+ assertNear(1f, rgb.green, tolerance = 0.01f, msg = "green")
+ assertNear(1f, rgb.blue, tolerance = 0.01f, msg = "blue")
+ }
+
+ @Test
+ fun blackLabToRgb() {
+ val lab = LabColor(l = 0f, a = 0f, b = 0f)
+ val rgb = lab.toRgb()
+ assertNear(0f, rgb.red, tolerance = 0.01f, msg = "red")
+ assertNear(0f, rgb.green, tolerance = 0.01f, msg = "green")
+ assertNear(0f, rgb.blue, tolerance = 0.01f, msg = "blue")
+ }
+
+ @Test
+ fun midGrayLabToRgb() {
+ // L=50 should give a mid-gray
+ val lab = LabColor(l = 50f, a = 0f, b = 0f)
+ val rgb = lab.toRgb()
+ // Mid gray L=50 is roughly sRGB 0.184 (not 0.5, due to gamma)
+ assertTrue(rgb.red > 0.1f && rgb.red < 0.6f, "red should be mid-range: ${rgb.red}")
+ assertNear(rgb.red, rgb.green, tolerance = 0.01f, msg = "gray R==G")
+ assertNear(rgb.red, rgb.blue, tolerance = 0.01f, msg = "gray R==B")
+ }
+
+ // ---- RGB -> LAB known colors ----
+
+ @Test
+ fun rgbWhiteToLab() {
+ // With XN/ZN matching the sRGB matrices' D65 white point, white maps to a*=0, b*=0
+ val rgb = RgbColor(1f, 1f, 1f)
+ val lab = rgb.toLab()
+ assertNear(100f, lab.l, tolerance = 0.01f, msg = "L")
+ assertNear(0f, lab.a, tolerance = 0.01f, msg = "a")
+ assertNear(0f, lab.b, tolerance = 0.01f, msg = "b")
+ }
+
+ @Test
+ fun rgbNeutralGrayToLab() {
+ // Any neutral gray must map to a*~0, b*~0
+ val lab = RgbColor(0.5f, 0.5f, 0.5f).toLab()
+ assertNear(0f, lab.a, tolerance = 0.01f, msg = "a")
+ assertNear(0f, lab.b, tolerance = 0.01f, msg = "b")
+ }
+
+ @Test
+ fun rgbDarkNeutralGrayToLab() {
+ // Dark gray goes through the linear (KAPPA) branch and must stay neutral too
+ val lab = RgbColor(0.01f, 0.01f, 0.01f).toLab()
+ assertNear(0f, lab.a, tolerance = 0.01f, msg = "a")
+ assertNear(0f, lab.b, tolerance = 0.01f, msg = "b")
+ }
+
+ @Test
+ fun rgbBlackToLab() {
+ val rgb = RgbColor(0f, 0f, 0f)
+ val lab = rgb.toLab()
+ assertNear(0f, lab.l, tolerance = 0.01f, msg = "L")
+ assertNear(0f, lab.a, tolerance = 0.01f, msg = "a")
+ assertNear(0f, lab.b, tolerance = 0.01f, msg = "b")
+ }
+
+ @Test
+ fun rgbRedToLab() {
+ val rgb = RgbColor(1f, 0f, 0f)
+ val lab = rgb.toLab()
+ // Red in Lab is approximately L=53.23, a=80.11, b=67.22
+ assertTrue(lab.l > 50f && lab.l < 56f, "L for red: ${lab.l}")
+ assertTrue(lab.a > 75f, "a for red: ${lab.a}")
+ assertTrue(lab.b > 60f, "b for red: ${lab.b}")
+ }
+
+ // ---- Round-trip ----
+
+ @Test
+ fun roundTripLab() {
+ val original = LabColor(l = 50f, a = 20f, b = -30f)
+ val roundTripped = original.toRgb().toLab()
+ assertNear(original.l, roundTripped.l, tolerance = labEps, msg = "L")
+ assertNear(original.a, roundTripped.a, tolerance = labEps, msg = "a")
+ assertNear(original.b, roundTripped.b, tolerance = labEps, msg = "b")
+ }
+
+ @Test
+ fun roundTripLabNonTrivial() {
+ val original = LabColor(l = 65.3f, a = -15.7f, b = 42.1f)
+ val roundTripped = original.toRgb().toLab()
+ assertNear(original.l, roundTripped.l, tolerance = labEps, msg = "L")
+ assertNear(original.a, roundTripped.a, tolerance = labEps, msg = "a")
+ assertNear(original.b, roundTripped.b, tolerance = labEps, msg = "b")
+ }
+
+ @Test
+ fun roundTripRgbThroughLab() {
+ val original = RgbColor(0.4f, 0.6f, 0.8f)
+ val roundTripped = original.toLab().toRgb()
+ assertNear(original.red, roundTripped.red, tolerance = 0.01f, msg = "red")
+ assertNear(original.green, roundTripped.green, tolerance = 0.01f, msg = "green")
+ assertNear(original.blue, roundTripped.blue, tolerance = 0.01f, msg = "blue")
+ }
+
+ // ---- Alpha preserved ----
+
+ @Test
+ fun alphaPreservedLabToRgb() {
+ val lab = LabColor(l = 50f, a = 10f, b = 10f, alpha = 0.502f)
+ val rgb = lab.toRgb()
+ assertEquals(0.502f, rgb.alpha)
+ }
+
+ @Test
+ fun alphaPreservedRgbToLab() {
+ val rgb = RgbColor(0.5f, 0.3f, 0.1f, alpha = 0.75f)
+ val lab = rgb.toLab()
+ assertEquals(0.75f, lab.alpha)
+ }
+
+ // ---- Int round-trip via ARGB ----
+
+ @Test
+ fun labToArgbIntAndBack() {
+ val lab = LabColor(l = 50f, a = 20f, b = -30f)
+ val argb = lab.toArgbInt()
+ val restored = argb.toLabColor()
+ // Going through 8-bit ARGB loses precision
+ assertNear(lab.l, restored.l, tolerance = 2f, msg = "L")
+ assertNear(lab.a, restored.a, tolerance = 2f, msg = "a")
+ assertNear(lab.b, restored.b, tolerance = 2f, msg = "b")
+ }
+
+ @Test
+ fun intToLabColor() {
+ val black = 0xFF000000.toInt()
+ val lab = black.toLabColor()
+ assertNear(0f, lab.l, tolerance = 0.5f)
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/RgbConversionsTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/RgbConversionsTest.kt
new file mode 100644
index 0000000..17c1ffe
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/RgbConversionsTest.kt
@@ -0,0 +1,85 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.RgbColor
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class RgbConversionsTest {
+
+ // ---- contrastColor: WCAG relative luminance, black text if luminance > 0.179 ----
+
+ @Test
+ fun contrastColorOnBlack() {
+ val contrast = RgbColor.Black.contrastColor()
+ assertEquals(RgbColor.White, contrast)
+ }
+
+ @Test
+ fun contrastColorOnWhite() {
+ val contrast = RgbColor.White.contrastColor()
+ assertEquals(RgbColor.Black, contrast)
+ }
+
+ @Test
+ fun contrastColorOnDarkRed() {
+ // luminance = 0.2126 * linearize(0.392) = ~0.027 -> <= 0.179 -> White
+ val contrast = RgbColor(red = 0.392f, green = 0f, blue = 0f).contrastColor()
+ assertEquals(RgbColor.White, contrast)
+ }
+
+ @Test
+ fun contrastColorOnBrightYellow() {
+ // luminance = 0.2126 + 0.7152 = 0.9278 -> > 0.179 -> Black
+ val contrast = RgbColor(red = 1f, green = 1f, blue = 0f).contrastColor()
+ assertEquals(RgbColor.Black, contrast)
+ }
+
+ @Test
+ fun contrastColorOnMidGray() {
+ // luminance = linearize(0.502) = ~0.216 -> > 0.179 -> Black
+ val contrast = RgbColor(0.502f, 0.502f, 0.502f).contrastColor()
+ assertEquals(RgbColor.Black, contrast)
+ }
+
+ @Test
+ fun contrastColorOnLightGray() {
+ // luminance = linearize(0.784) = ~0.577 -> > 0.179 -> Black
+ val contrast = RgbColor(0.784f, 0.784f, 0.784f).contrastColor()
+ assertEquals(RgbColor.Black, contrast)
+ }
+
+ @Test
+ fun contrastColorOnPureRed() {
+ // luminance = 0.2126 -> > 0.179 -> Black
+ val contrast = RgbColor.Red.contrastColor()
+ assertEquals(RgbColor.Black, contrast)
+ }
+
+ @Test
+ fun contrastColorOnPureGreen() {
+ // luminance = 0.7152 -> > 0.179 -> Black
+ val contrast = RgbColor.Green.contrastColor()
+ assertEquals(RgbColor.Black, contrast)
+ }
+
+ @Test
+ fun contrastColorOnPureBlue() {
+ // luminance = 0.0722 -> <= 0.179 -> White
+ val contrast = RgbColor.Blue.contrastColor()
+ assertEquals(RgbColor.White, contrast)
+ }
+
+ @Test
+ fun contrastColorJustBelowThreshold() {
+ // luminance = linearize(0.45) = ~0.171 -> <= 0.179 -> White
+ val contrast = RgbColor(0.45f, 0.45f, 0.45f).contrastColor()
+ assertEquals(RgbColor.White, contrast)
+ }
+
+ @Test
+ fun contrastColorJustAboveThreshold() {
+ // luminance = linearize(0.47) = ~0.187 -> > 0.179 -> Black
+ val contrast = RgbColor(0.47f, 0.47f, 0.47f).contrastColor()
+ assertEquals(RgbColor.Black, contrast)
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/CmykColorTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/CmykColorTest.kt
new file mode 100644
index 0000000..3899b03
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/CmykColorTest.kt
@@ -0,0 +1,158 @@
+package codes.side.colorpicker.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class CmykColorTest {
+
+ @Test
+ fun defaultValues() {
+ val c = CmykColor()
+ assertEquals(0f, c.cyan)
+ assertEquals(0f, c.magenta)
+ assertEquals(0f, c.yellow)
+ assertEquals(0f, c.key)
+ assertEquals(1f, c.alpha)
+ }
+
+ // ---- Boundaries ----
+
+ @Test
+ fun cyanBoundaries() {
+ assertEquals(0f, CmykColor(cyan = 0f).cyan)
+ assertEquals(1f, CmykColor(cyan = 1f).cyan)
+ assertFailsWith { CmykColor(cyan = 1.01f) }
+ assertFailsWith { CmykColor(cyan = -0.01f) }
+ }
+
+ @Test
+ fun magentaBoundaries() {
+ assertEquals(0f, CmykColor(magenta = 0f).magenta)
+ assertEquals(1f, CmykColor(magenta = 1f).magenta)
+ assertFailsWith { CmykColor(magenta = 1.01f) }
+ assertFailsWith { CmykColor(magenta = -0.01f) }
+ }
+
+ @Test
+ fun yellowBoundaries() {
+ assertEquals(0f, CmykColor(yellow = 0f).yellow)
+ assertEquals(1f, CmykColor(yellow = 1f).yellow)
+ assertFailsWith { CmykColor(yellow = 1.01f) }
+ assertFailsWith { CmykColor(yellow = -0.01f) }
+ }
+
+ @Test
+ fun keyBoundaries() {
+ assertEquals(0f, CmykColor(key = 0f).key)
+ assertEquals(1f, CmykColor(key = 1f).key)
+ assertFailsWith { CmykColor(key = 1.01f) }
+ assertFailsWith { CmykColor(key = -0.01f) }
+ }
+
+ @Test
+ fun alphaBoundaries() {
+ assertEquals(0f, CmykColor(alpha = 0f).alpha)
+ assertEquals(1f, CmykColor(alpha = 1f).alpha)
+ assertFailsWith { CmykColor(alpha = 1.01f) }
+ assertFailsWith { CmykColor(alpha = -0.01f) }
+ }
+
+ // ---- Signed zero normalization ----
+
+ @Test
+ fun negativeZeroEqualsZero() {
+ val negativeZero = CmykColor(cyan = -0.0f, magenta = -0.0f, yellow = -0.0f, key = -0.0f)
+ val zero = CmykColor(cyan = 0f, magenta = 0f, yellow = 0f, key = 0f)
+ assertEquals(zero, negativeZero)
+ assertEquals(zero.hashCode(), negativeZero.hashCode())
+ }
+
+ // ---- Int accessors ----
+
+ @Test
+ fun intAccessorsMidValues() {
+ val c = CmykColor(cyan = 0.5f, magenta = 0.25f, yellow = 0.75f, key = 0.1f, alpha = 128f / 255f)
+ assertEquals(50, c.intCyan)
+ assertEquals(25, c.intMagenta)
+ assertEquals(75, c.intYellow)
+ assertEquals(10, c.intKey)
+ assertEquals(128, c.intAlpha)
+ }
+
+ @Test
+ fun intAccessorsAtZero() {
+ val c = CmykColor(cyan = 0f, magenta = 0f, yellow = 0f, key = 0f, alpha = 0f)
+ assertEquals(0, c.intCyan)
+ assertEquals(0, c.intMagenta)
+ assertEquals(0, c.intYellow)
+ assertEquals(0, c.intKey)
+ assertEquals(0, c.intAlpha)
+ }
+
+ @Test
+ fun intAccessorsAtMax() {
+ val c = CmykColor(cyan = 1f, magenta = 1f, yellow = 1f, key = 1f, alpha = 1f)
+ assertEquals(100, c.intCyan)
+ assertEquals(100, c.intMagenta)
+ assertEquals(100, c.intYellow)
+ assertEquals(100, c.intKey)
+ assertEquals(255, c.intAlpha)
+ }
+
+ // ---- Companion constants ----
+
+ @Test
+ fun companionBlack() {
+ assertEquals(1f, CmykColor.Black.key)
+ assertEquals(0f, CmykColor.Black.cyan)
+ assertEquals(0f, CmykColor.Black.magenta)
+ assertEquals(0f, CmykColor.Black.yellow)
+ }
+
+ @Test
+ fun companionWhite() {
+ assertEquals(0f, CmykColor.White.key)
+ assertEquals(0f, CmykColor.White.cyan)
+ assertEquals(0f, CmykColor.White.magenta)
+ assertEquals(0f, CmykColor.White.yellow)
+ }
+
+ // ---- fromInt factory ----
+
+ @Test
+ fun fromIntBasicConversion() {
+ val c = CmykColor.fromInt(cyan = 50, magenta = 25, yellow = 75, key = 10, alpha = 200)
+ assertEquals(0.5f, c.cyan)
+ assertEquals(0.25f, c.magenta)
+ assertEquals(0.75f, c.yellow)
+ assertEquals(0.1f, c.key)
+ assertEquals(200 / 255f, c.alpha)
+ }
+
+ @Test
+ fun fromIntDefaultAlpha() {
+ val c = CmykColor.fromInt(cyan = 0, magenta = 0, yellow = 0, key = 0)
+ assertEquals(1f, c.alpha)
+ }
+
+ @Test
+ fun fromIntClampsValues() {
+ val c = CmykColor.fromInt(cyan = 200, magenta = -5, yellow = 150, key = 300, alpha = 999)
+ assertEquals(1f, c.cyan)
+ assertEquals(0f, c.magenta)
+ assertEquals(1f, c.yellow)
+ assertEquals(1f, c.key)
+ assertEquals(1f, c.alpha)
+ }
+
+ // ---- data class semantics ----
+
+ @Test
+ fun equalityByValue() {
+ val a = CmykColor(cyan = 0.3f, magenta = 0.4f, yellow = 0.5f, key = 0.6f, alpha = 0.7f)
+ val b = CmykColor(cyan = 0.3f, magenta = 0.4f, yellow = 0.5f, key = 0.6f, alpha = 0.7f)
+ assertEquals(a, b)
+ assertEquals(a.hashCode(), b.hashCode())
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/ColorModelTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/ColorModelTest.kt
new file mode 100644
index 0000000..0e53b79
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/ColorModelTest.kt
@@ -0,0 +1,156 @@
+package codes.side.colorpicker.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotEquals
+
+class ColorModelTest {
+
+ // ---- HslColor defaults & validation ----
+
+ @Test
+ fun hslColorDefaultValues() {
+ val hsl = HslColor()
+ assertEquals(0f, hsl.hue)
+ assertEquals(1f, hsl.saturation)
+ assertEquals(0.5f, hsl.lightness)
+ assertEquals(1f, hsl.alpha)
+ }
+
+ @Test
+ fun hslColorRejectsInvalidHue() {
+ assertFailsWith { HslColor(hue = -0.1f) }
+ assertFailsWith { HslColor(hue = 360.1f) }
+ }
+
+ @Test
+ fun hslColorRejectsInvalidSaturation() {
+ assertFailsWith { HslColor(saturation = -0.01f) }
+ assertFailsWith { HslColor(saturation = 1.01f) }
+ }
+
+ @Test
+ fun hslColorRejectsInvalidLightness() {
+ assertFailsWith { HslColor(lightness = -0.01f) }
+ assertFailsWith { HslColor(lightness = 1.01f) }
+ }
+
+ @Test
+ fun hslColorRejectsInvalidAlpha() {
+ assertFailsWith { HslColor(alpha = -0.01f) }
+ assertFailsWith { HslColor(alpha = 1.01f) }
+ }
+
+ @Test
+ fun hslIntAccessors() {
+ val hsl = HslColor(hue = 180f, saturation = 0.5f, lightness = 0.25f, alpha = 0.5f)
+ assertEquals(180, hsl.intHue)
+ assertEquals(50, hsl.intSaturation)
+ assertEquals(25, hsl.intLightness)
+ assertEquals(128, hsl.intAlpha)
+ }
+
+ @Test
+ fun hslCopyCreatesNewInstance() {
+ val original = HslColor(hue = 100f, saturation = 0.8f, lightness = 0.6f)
+ val copy = original.copy(hue = 200f)
+ assertNotEquals(original, copy)
+ assertEquals(200f, copy.hue)
+ assertEquals(0.8f, copy.saturation)
+ }
+
+ // ---- RgbColor validation ----
+
+ @Test
+ fun rgbColorRejectsInvalidValues() {
+ assertFailsWith { RgbColor(red = 1.01f) }
+ assertFailsWith { RgbColor(green = -0.01f) }
+ assertFailsWith { RgbColor(blue = 1.5f) }
+ assertFailsWith { RgbColor(alpha = -0.1f) }
+ }
+
+ // ---- CmykColor validation ----
+
+ @Test
+ fun cmykColorRejectsInvalidValues() {
+ assertFailsWith { CmykColor(cyan = 1.01f) }
+ assertFailsWith { CmykColor(key = -0.01f) }
+ assertFailsWith { CmykColor(magenta = 1.5f) }
+ assertFailsWith { CmykColor(alpha = -0.01f) }
+ }
+
+ // ---- LabColor validation ----
+
+ @Test
+ fun labColorRanges() {
+ val lab = LabColor(l = 0f, a = -128f, b = 127f)
+ assertEquals(0f, lab.l)
+ assertEquals(-128f, lab.a)
+ assertEquals(127f, lab.b)
+
+ assertFailsWith { LabColor(a = -128.1f) }
+ assertFailsWith { LabColor(b = 127.1f) }
+ assertFailsWith { LabColor(l = -0.1f) }
+ assertFailsWith { LabColor(l = 100.1f) }
+ }
+
+ // ---- PickerColor sealed interface ----
+
+ @Test
+ fun alphaIsFloat() {
+ val color = HslColor(alpha = 0.502f)
+ assertEquals(0.502f, color.alpha)
+ }
+
+ @Test
+ fun sealedInterfacePolymorphism() {
+ val colors: List = listOf(
+ HslColor(),
+ RgbColor(),
+ CmykColor(),
+ LabColor(),
+ )
+ assertEquals(4, colors.size)
+ colors.forEach { assertEquals(1f, it.alpha) }
+ }
+
+ // ---- fromInt factories ----
+
+ @Test
+ fun hslFromIntFactory() {
+ val hsl = HslColor.fromInt(hue = 180, saturation = 50, lightness = 25, alpha = 128)
+ assertEquals(180f, hsl.hue)
+ assertEquals(0.5f, hsl.saturation)
+ assertEquals(0.25f, hsl.lightness)
+ assertEquals(128 / 255f, hsl.alpha)
+ }
+
+ @Test
+ fun rgbFromIntFactory() {
+ val rgb = RgbColor.fromInt(red = 128, green = 64, blue = 32, alpha = 200)
+ assertEquals(128 / 255f, rgb.red)
+ assertEquals(64 / 255f, rgb.green)
+ assertEquals(32 / 255f, rgb.blue)
+ assertEquals(200 / 255f, rgb.alpha)
+ }
+
+ @Test
+ fun cmykFromIntFactory() {
+ val cmyk = CmykColor.fromInt(cyan = 50, magenta = 25, yellow = 75, key = 10, alpha = 128)
+ assertEquals(0.5f, cmyk.cyan)
+ assertEquals(0.25f, cmyk.magenta)
+ assertEquals(0.75f, cmyk.yellow)
+ assertEquals(0.1f, cmyk.key)
+ assertEquals(128 / 255f, cmyk.alpha)
+ }
+
+ @Test
+ fun labFromIntFactory() {
+ val lab = LabColor.fromInt(l = 50, a = -60, b = 80, alpha = 200)
+ assertEquals(50f, lab.l)
+ assertEquals(-60f, lab.a)
+ assertEquals(80f, lab.b)
+ assertEquals(200 / 255f, lab.alpha)
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/HslColorTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/HslColorTest.kt
new file mode 100644
index 0000000..245e27b
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/HslColorTest.kt
@@ -0,0 +1,228 @@
+package codes.side.colorpicker.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class HslColorTest {
+
+ @Test
+ fun defaultValues() {
+ val hsl = HslColor()
+ assertEquals(0f, hsl.hue)
+ assertEquals(1f, hsl.saturation)
+ assertEquals(0.5f, hsl.lightness)
+ assertEquals(1f, hsl.alpha)
+ }
+
+ // ---- Hue boundaries ----
+
+ @Test
+ fun boundaryHue360IsAcceptedAndNormalizedToZero() {
+ val hsl = HslColor(hue = 360f)
+ assertEquals(0f, hsl.hue)
+ }
+
+ @Test
+ fun hue360EqualsHue0() {
+ val fromMax = HslColor(hue = 360f, saturation = 0.5f, lightness = 0.5f)
+ val fromZero = HslColor(hue = 0f, saturation = 0.5f, lightness = 0.5f)
+ assertEquals(fromZero, fromMax)
+ assertEquals(fromZero.hashCode(), fromMax.hashCode())
+ }
+
+ @Test
+ fun negativeZeroHueEqualsZeroHue() {
+ val negativeZero = HslColor(hue = -0.0f, saturation = -0.0f, lightness = -0.0f, alpha = 1f)
+ val zero = HslColor(hue = 0f, saturation = 0f, lightness = 0f, alpha = 1f)
+ assertEquals(zero, negativeZero)
+ assertEquals(zero.hashCode(), negativeZero.hashCode())
+ }
+
+ @Test
+ fun boundaryHue0IsValid() {
+ val hsl = HslColor(hue = 0f)
+ assertEquals(0f, hsl.hue)
+ }
+
+ @Test
+ fun hueAbove360Rejected() {
+ assertFailsWith { HslColor(hue = 360.01f) }
+ }
+
+ @Test
+ fun hueNegativeRejected() {
+ assertFailsWith { HslColor(hue = -0.01f) }
+ }
+
+ @Test
+ fun hueFractionalValues() {
+ val hsl = HslColor(hue = 210.5f)
+ assertEquals(210.5f, hsl.hue)
+ }
+
+ // ---- Saturation boundaries ----
+
+ @Test
+ fun saturationBoundaries() {
+ assertEquals(0f, HslColor(saturation = 0f).saturation)
+ assertEquals(1f, HslColor(saturation = 1f).saturation)
+ assertFailsWith { HslColor(saturation = 1.01f) }
+ assertFailsWith { HslColor(saturation = -0.01f) }
+ }
+
+ @Test
+ fun saturationFractionalValue() {
+ val hsl = HslColor(saturation = 0.47f)
+ assertEquals(0.47f, hsl.saturation)
+ }
+
+ // ---- Lightness boundaries ----
+
+ @Test
+ fun lightnessBoundaries() {
+ assertEquals(0f, HslColor(lightness = 0f).lightness)
+ assertEquals(1f, HslColor(lightness = 1f).lightness)
+ assertFailsWith { HslColor(lightness = 1.01f) }
+ assertFailsWith { HslColor(lightness = -0.01f) }
+ }
+
+ @Test
+ fun lightnessFractionalValue() {
+ val hsl = HslColor(lightness = 0.63f)
+ assertEquals(0.63f, hsl.lightness)
+ }
+
+ // ---- Alpha boundaries ----
+
+ @Test
+ fun alphaBoundaries() {
+ assertEquals(0f, HslColor(alpha = 0f).alpha)
+ assertEquals(1f, HslColor(alpha = 1f).alpha)
+ assertFailsWith { HslColor(alpha = 1.01f) }
+ assertFailsWith { HslColor(alpha = -0.01f) }
+ }
+
+ @Test
+ fun alphaFractionalValue() {
+ val hsl = HslColor(alpha = 0.75f)
+ assertEquals(0.75f, hsl.alpha)
+ }
+
+ // ---- Int accessors ----
+
+ @Test
+ fun intAccessorsMidValues() {
+ val hsl = HslColor(hue = 180f, saturation = 0.5f, lightness = 0.25f, alpha = 128f / 255f)
+ assertEquals(180, hsl.intHue)
+ assertEquals(50, hsl.intSaturation)
+ assertEquals(25, hsl.intLightness)
+ assertEquals(128, hsl.intAlpha)
+ }
+
+ @Test
+ fun intAccessorsAtZero() {
+ val hsl = HslColor(hue = 0f, saturation = 0f, lightness = 0f, alpha = 0f)
+ assertEquals(0, hsl.intHue)
+ assertEquals(0, hsl.intSaturation)
+ assertEquals(0, hsl.intLightness)
+ assertEquals(0, hsl.intAlpha)
+ }
+
+ @Test
+ fun intAccessorsAtMax() {
+ val hsl = HslColor(hue = 359.6f, saturation = 1f, lightness = 1f, alpha = 1f)
+ assertEquals(360, hsl.intHue)
+ assertEquals(100, hsl.intSaturation)
+ assertEquals(100, hsl.intLightness)
+ assertEquals(255, hsl.intAlpha)
+ }
+
+ @Test
+ fun intHueIsZeroForNormalizedHue360() {
+ val hsl = HslColor(hue = 360f)
+ assertEquals(0, hsl.intHue)
+ }
+
+ @Test
+ fun intHueRoundsCorrectly() {
+ val hsl = HslColor(hue = 210.5f)
+ assertEquals(211, hsl.intHue) // roundToInt rounds 210.5 -> 211 (banker's toward even on .5, but 210.5 -> 211 is nearest)
+ }
+
+ // ---- Companion constants ----
+
+ @Test
+ fun companionBlack() {
+ assertEquals(0f, HslColor.Black.lightness)
+ assertEquals(0f, HslColor.Black.saturation)
+ }
+
+ @Test
+ fun companionWhite() {
+ assertEquals(1f, HslColor.White.lightness)
+ assertEquals(0f, HslColor.White.saturation)
+ }
+
+ @Test
+ fun companionRed() {
+ assertEquals(0f, HslColor.Red.hue)
+ assertEquals(1f, HslColor.Red.saturation)
+ assertEquals(0.5f, HslColor.Red.lightness)
+ }
+
+ // ---- value semantics ----
+
+ @Test
+ fun dataCopyCopiesIndependently() {
+ val original = HslColor(hue = 100f, saturation = 0.8f, lightness = 0.6f)
+ val copy = original.copy(hue = 200f)
+ assertEquals(100f, original.hue)
+ assertEquals(200f, copy.hue)
+ assertEquals(0.8f, copy.saturation)
+ }
+
+ @Test
+ fun equalityByValue() {
+ val a = HslColor(hue = 42f, saturation = 0.5f, lightness = 0.6f, alpha = 0.78f)
+ val b = HslColor(hue = 42f, saturation = 0.5f, lightness = 0.6f, alpha = 0.78f)
+ assertEquals(a, b)
+ assertEquals(a.hashCode(), b.hashCode())
+ }
+
+ // ---- fromInt factory ----
+
+ @Test
+ fun fromIntBasicConversion() {
+ val hsl = HslColor.fromInt(hue = 180, saturation = 50, lightness = 25, alpha = 255)
+ assertEquals(180f, hsl.hue)
+ assertEquals(0.5f, hsl.saturation)
+ assertEquals(0.25f, hsl.lightness)
+ assertEquals(1f, hsl.alpha)
+ }
+
+ @Test
+ fun fromIntDefaultAlpha() {
+ val hsl = HslColor.fromInt(hue = 0, saturation = 100, lightness = 50)
+ assertEquals(1f, hsl.alpha)
+ }
+
+ @Test
+ fun fromIntClampsValues() {
+ val hsl = HslColor.fromInt(hue = 999, saturation = 200, lightness = -10, alpha = 300)
+ // hue clamps to 360, which the constructor normalizes to 0
+ assertEquals(0f, hsl.hue)
+ assertEquals(1f, hsl.saturation)
+ assertEquals(0f, hsl.lightness)
+ assertEquals(1f, hsl.alpha)
+ }
+
+ @Test
+ fun fromIntClampsNegatives() {
+ val hsl = HslColor.fromInt(hue = -10, saturation = -5, lightness = -1, alpha = -1)
+ assertEquals(0f, hsl.hue)
+ assertEquals(0f, hsl.saturation)
+ assertEquals(0f, hsl.lightness)
+ assertEquals(0f, hsl.alpha)
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/LabColorTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/LabColorTest.kt
new file mode 100644
index 0000000..f5f446b
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/LabColorTest.kt
@@ -0,0 +1,173 @@
+package codes.side.colorpicker.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class LabColorTest {
+
+ @Test
+ fun defaultValues() {
+ val lab = LabColor()
+ assertEquals(50f, lab.l)
+ assertEquals(0f, lab.a)
+ assertEquals(0f, lab.b)
+ assertEquals(1f, lab.alpha)
+ }
+
+ // ---- L boundaries ----
+
+ @Test
+ fun lBoundaries() {
+ assertEquals(0f, LabColor(l = 0f).l)
+ assertEquals(100f, LabColor(l = 100f).l)
+ assertFailsWith { LabColor(l = 100.1f) }
+ assertFailsWith { LabColor(l = -0.1f) }
+ }
+
+ @Test
+ fun lFractionalValue() {
+ val lab = LabColor(l = 53.23f)
+ assertEquals(53.23f, lab.l)
+ }
+
+ // ---- A boundaries ----
+
+ @Test
+ fun aBoundaries() {
+ assertEquals(-128f, LabColor(a = -128f).a)
+ assertEquals(127f, LabColor(a = 127f).a)
+ assertFailsWith { LabColor(a = -128.1f) }
+ assertFailsWith { LabColor(a = 127.1f) }
+ }
+
+ @Test
+ fun aFractionalValue() {
+ val lab = LabColor(a = -30.5f)
+ assertEquals(-30.5f, lab.a)
+ }
+
+ // ---- Signed zero normalization ----
+
+ @Test
+ fun negativeZeroEqualsZero() {
+ val negativeZero = LabColor(l = -0.0f, a = -0.0f, b = -0.0f)
+ val zero = LabColor(l = 0f, a = 0f, b = 0f)
+ assertEquals(zero, negativeZero)
+ assertEquals(zero.hashCode(), negativeZero.hashCode())
+ }
+
+ // ---- B boundaries ----
+
+ @Test
+ fun bBoundaries() {
+ assertEquals(-128f, LabColor(b = -128f).b)
+ assertEquals(127f, LabColor(b = 127f).b)
+ assertFailsWith { LabColor(b = -128.1f) }
+ assertFailsWith { LabColor(b = 127.1f) }
+ }
+
+ @Test
+ fun bFractionalValue() {
+ val lab = LabColor(b = 79.84f)
+ assertEquals(79.84f, lab.b)
+ }
+
+ // ---- Alpha boundaries ----
+
+ @Test
+ fun alphaBoundaries() {
+ assertEquals(0f, LabColor(alpha = 0f).alpha)
+ assertEquals(1f, LabColor(alpha = 1f).alpha)
+ assertFailsWith { LabColor(alpha = 1.01f) }
+ assertFailsWith { LabColor(alpha = -0.01f) }
+ }
+
+ // ---- Int accessors ----
+
+ @Test
+ fun intAccessors() {
+ val lab = LabColor(l = 53.23f, a = -30.5f, b = 79.84f, alpha = 0.75f)
+ assertEquals(53, lab.intL)
+ assertEquals(-30, lab.intA) // -30.5 rounds to -30 (banker's rounding: half to even)
+ assertEquals(80, lab.intB)
+ assertEquals(191, lab.intAlpha) // 0.75 * 255 = 191.25 -> 191
+ }
+
+ @Test
+ fun intAccessorsAtZero() {
+ val lab = LabColor(l = 0f, a = 0f, b = 0f, alpha = 0f)
+ assertEquals(0, lab.intL)
+ assertEquals(0, lab.intA)
+ assertEquals(0, lab.intB)
+ assertEquals(0, lab.intAlpha)
+ }
+
+ @Test
+ fun intAccessorsAtExtremes() {
+ val lab = LabColor(l = 100f, a = -128f, b = 127f, alpha = 1f)
+ assertEquals(100, lab.intL)
+ assertEquals(-128, lab.intA)
+ assertEquals(127, lab.intB)
+ assertEquals(255, lab.intAlpha)
+ }
+
+ // ---- Companion constants ----
+
+ @Test
+ fun companionBlack() {
+ assertEquals(0f, LabColor.Black.l)
+ assertEquals(0f, LabColor.Black.a)
+ assertEquals(0f, LabColor.Black.b)
+ }
+
+ @Test
+ fun companionWhite() {
+ assertEquals(100f, LabColor.White.l)
+ assertEquals(0f, LabColor.White.a)
+ assertEquals(0f, LabColor.White.b)
+ }
+
+ // ---- fromInt factory ----
+
+ @Test
+ fun fromIntBasicConversion() {
+ val lab = LabColor.fromInt(l = 50, a = -60, b = 80, alpha = 200)
+ assertEquals(50f, lab.l)
+ assertEquals(-60f, lab.a)
+ assertEquals(80f, lab.b)
+ assertEquals(200 / 255f, lab.alpha)
+ }
+
+ @Test
+ fun fromIntDefaultAlpha() {
+ val lab = LabColor.fromInt(l = 50, a = 0, b = 0)
+ assertEquals(1f, lab.alpha)
+ }
+
+ @Test
+ fun fromIntClampsValues() {
+ val lab = LabColor.fromInt(l = 200, a = -200, b = 200, alpha = 300)
+ assertEquals(100f, lab.l)
+ assertEquals(-128f, lab.a)
+ assertEquals(127f, lab.b)
+ assertEquals(1f, lab.alpha)
+ }
+
+ @Test
+ fun fromIntClampsNegatives() {
+ val lab = LabColor.fromInt(l = -10, a = 0, b = 0, alpha = -5)
+ assertEquals(0f, lab.l)
+ assertEquals(0f, lab.alpha)
+ }
+
+ // ---- data class semantics ----
+
+ @Test
+ fun equalityByValue() {
+ val a = LabColor(l = 53.23f, a = -30.5f, b = 79.84f, alpha = 0.5f)
+ val b = LabColor(l = 53.23f, a = -30.5f, b = 79.84f, alpha = 0.5f)
+ assertEquals(a, b)
+ assertEquals(a.hashCode(), b.hashCode())
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/RgbColorTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/RgbColorTest.kt
new file mode 100644
index 0000000..34a778e
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/RgbColorTest.kt
@@ -0,0 +1,177 @@
+package codes.side.colorpicker.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class RgbColorTest {
+
+ @Test
+ fun defaultValues() {
+ val rgb = RgbColor()
+ assertEquals(0f, rgb.red)
+ assertEquals(0f, rgb.green)
+ assertEquals(0f, rgb.blue)
+ assertEquals(1f, rgb.alpha)
+ }
+
+ // ---- Boundaries ----
+
+ @Test
+ fun redBoundaries() {
+ assertEquals(0f, RgbColor(red = 0f).red)
+ assertEquals(1f, RgbColor(red = 1f).red)
+ assertFailsWith { RgbColor(red = 1.01f) }
+ assertFailsWith { RgbColor(red = -0.01f) }
+ }
+
+ @Test
+ fun greenBoundaries() {
+ assertEquals(0f, RgbColor(green = 0f).green)
+ assertEquals(1f, RgbColor(green = 1f).green)
+ assertFailsWith { RgbColor(green = 1.01f) }
+ assertFailsWith { RgbColor(green = -0.01f) }
+ }
+
+ @Test
+ fun blueBoundaries() {
+ assertEquals(0f, RgbColor(blue = 0f).blue)
+ assertEquals(1f, RgbColor(blue = 1f).blue)
+ assertFailsWith { RgbColor(blue = 1.01f) }
+ assertFailsWith { RgbColor(blue = -0.01f) }
+ }
+
+ @Test
+ fun alphaBoundaries() {
+ assertEquals(0f, RgbColor(alpha = 0f).alpha)
+ assertEquals(1f, RgbColor(alpha = 1f).alpha)
+ assertFailsWith { RgbColor(alpha = 1.01f) }
+ assertFailsWith { RgbColor(alpha = -0.01f) }
+ }
+
+ // ---- Int accessors ----
+
+ @Test
+ fun intAccessors() {
+ val rgb = RgbColor(red = 128f / 255f, green = 64f / 255f, blue = 32f / 255f, alpha = 200f / 255f)
+ assertEquals(128, rgb.intRed)
+ assertEquals(64, rgb.intGreen)
+ assertEquals(32, rgb.intBlue)
+ assertEquals(200, rgb.intAlpha)
+ }
+
+ @Test
+ fun intAccessorsAtZero() {
+ val rgb = RgbColor(red = 0f, green = 0f, blue = 0f, alpha = 0f)
+ assertEquals(0, rgb.intRed)
+ assertEquals(0, rgb.intGreen)
+ assertEquals(0, rgb.intBlue)
+ assertEquals(0, rgb.intAlpha)
+ }
+
+ @Test
+ fun intAccessorsAtMax() {
+ val rgb = RgbColor(red = 1f, green = 1f, blue = 1f, alpha = 1f)
+ assertEquals(255, rgb.intRed)
+ assertEquals(255, rgb.intGreen)
+ assertEquals(255, rgb.intBlue)
+ assertEquals(255, rgb.intAlpha)
+ }
+
+ @Test
+ fun intAccessorMidpoint() {
+ val rgb = RgbColor(red = 0.5f)
+ assertEquals(128, rgb.intRed) // 0.5 * 255 = 127.5 -> rounds to 128
+ }
+
+ // ---- Companion constants ----
+
+ @Test
+ fun companionBlack() {
+ val c = RgbColor.Black
+ assertEquals(0f, c.red)
+ assertEquals(0f, c.green)
+ assertEquals(0f, c.blue)
+ assertEquals(1f, c.alpha)
+ }
+
+ @Test
+ fun companionWhite() {
+ val c = RgbColor.White
+ assertEquals(1f, c.red)
+ assertEquals(1f, c.green)
+ assertEquals(1f, c.blue)
+ }
+
+ @Test
+ fun companionPrimaryColors() {
+ assertEquals(1f, RgbColor.Red.red)
+ assertEquals(0f, RgbColor.Red.green)
+ assertEquals(0f, RgbColor.Red.blue)
+
+ assertEquals(0f, RgbColor.Green.red)
+ assertEquals(1f, RgbColor.Green.green)
+ assertEquals(0f, RgbColor.Green.blue)
+
+ assertEquals(0f, RgbColor.Blue.red)
+ assertEquals(0f, RgbColor.Blue.green)
+ assertEquals(1f, RgbColor.Blue.blue)
+ }
+
+ // ---- fromInt factory ----
+
+ @Test
+ fun fromIntBasicConversion() {
+ val rgb = RgbColor.fromInt(red = 128, green = 64, blue = 32, alpha = 200)
+ assertEquals(128 / 255f, rgb.red)
+ assertEquals(64 / 255f, rgb.green)
+ assertEquals(32 / 255f, rgb.blue)
+ assertEquals(200 / 255f, rgb.alpha)
+ }
+
+ @Test
+ fun fromIntDefaultAlpha() {
+ val rgb = RgbColor.fromInt(red = 0, green = 0, blue = 0)
+ assertEquals(1f, rgb.alpha)
+ }
+
+ @Test
+ fun fromIntClampsValues() {
+ val rgb = RgbColor.fromInt(red = 300, green = -10, blue = 999, alpha = 256)
+ assertEquals(1f, rgb.red)
+ assertEquals(0f, rgb.green)
+ assertEquals(1f, rgb.blue)
+ assertEquals(1f, rgb.alpha)
+ }
+
+ // ---- value semantics ----
+
+ @Test
+ fun equalityByValue() {
+ val a = RgbColor(red = 0.3f, green = 0.5f, blue = 0.7f, alpha = 0.9f)
+ val b = RgbColor(red = 0.3f, green = 0.5f, blue = 0.7f, alpha = 0.9f)
+ assertEquals(a, b)
+ assertEquals(a.hashCode(), b.hashCode())
+ }
+
+ @Test
+ fun negativeZeroEqualsZero() {
+ val negativeZero = RgbColor(red = -0.0f, green = -0.0f, blue = -0.0f, alpha = 1f)
+ val zero = RgbColor(red = 0f, green = 0f, blue = 0f, alpha = 1f)
+ assertEquals(zero, negativeZero)
+ assertEquals(zero.hashCode(), negativeZero.hashCode())
+ assertEquals(0f, negativeZero.red)
+ }
+
+ @Test
+ fun copyNormalizesNegativeZero() {
+ val copy = RgbColor(red = 0.5f).copy(red = -0.0f)
+ assertEquals(RgbColor(red = 0f), copy)
+ }
+
+ @Test
+ fun fractionalRedValue() {
+ val rgb = RgbColor(red = 0.392f)
+ assertEquals(0.392f, rgb.red)
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/state/ColorPickerStateTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/state/ColorPickerStateTest.kt
new file mode 100644
index 0000000..d3c445c
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/state/ColorPickerStateTest.kt
@@ -0,0 +1,640 @@
+package codes.side.colorpicker.state
+
+import androidx.compose.runtime.saveable.SaverScope
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.snapshots.Snapshot
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+
+class ColorPickerStateTest {
+
+ private val eps = 1e-5f
+
+ private fun assertNear(expected: Float, actual: Float, tolerance: Float = eps, msg: String = "") {
+ assertTrue(
+ abs(expected - actual) <= tolerance,
+ "$msg expected=$expected actual=$actual diff=${abs(expected - actual)}"
+ )
+ }
+
+ private fun createState(color: HslColor = HslColor()) = ColorPickerState(color)
+
+ // ---- Initial state ----
+
+ @Test
+ fun initialColor() {
+ val state = createState(HslColor(hue = 200f, saturation = 0.8f, lightness = 0.5f))
+ assertEquals(200f, state.hslColor.hue)
+ assertEquals(0.8f, state.hslColor.saturation)
+ assertEquals(0.5f, state.hslColor.lightness)
+ }
+
+ @Test
+ fun initialIsInteractingFalse() {
+ val state = createState()
+ assertFalse(state.isInteracting)
+ }
+
+ @Test
+ fun initialAlphaIsOne() {
+ val state = createState()
+ assertEquals(1f, state.hslColor.alpha)
+ }
+
+ // ---- updateHue (Float) ----
+
+ @Test
+ fun updateHue() {
+ val state = createState()
+ state.updateHue(180f)
+ assertEquals(180f, state.hslColor.hue)
+ }
+
+ @Test
+ fun updateHueFractional() {
+ val state = createState()
+ state.updateHue(210.5f)
+ assertEquals(210.5f, state.hslColor.hue)
+ }
+
+ @Test
+ fun updateHueClampsHigh() {
+ val state = createState()
+ state.updateHue(999f)
+ // Clamped to 360, which HslColor normalizes to the equivalent 0.
+ assertEquals(0f, state.hslColor.hue)
+ }
+
+ @Test
+ fun updateHueClampsLow() {
+ val state = createState()
+ state.updateHue(-10f)
+ assertEquals(0f, state.hslColor.hue)
+ }
+
+ // ---- updateSaturation (Float) ----
+
+ @Test
+ fun updateSaturation() {
+ val state = createState()
+ state.updateSaturation(0.75f)
+ assertEquals(0.75f, state.hslColor.saturation)
+ }
+
+ @Test
+ fun updateSaturationClampsHigh() {
+ val state = createState()
+ state.updateSaturation(2f)
+ assertEquals(1f, state.hslColor.saturation)
+ }
+
+ @Test
+ fun updateSaturationClampsLow() {
+ val state = createState()
+ state.updateSaturation(-0.5f)
+ assertEquals(0f, state.hslColor.saturation)
+ }
+
+ // ---- updateLightness (Float) ----
+
+ @Test
+ fun updateLightness() {
+ val state = createState()
+ state.updateLightness(0.3f)
+ assertEquals(0.3f, state.hslColor.lightness)
+ }
+
+ @Test
+ fun updateLightnessClampsHigh() {
+ val state = createState()
+ state.updateLightness(1.5f)
+ assertEquals(1f, state.hslColor.lightness)
+ }
+
+ @Test
+ fun updateLightnessClampsLow() {
+ val state = createState()
+ state.updateLightness(-0.1f)
+ assertEquals(0f, state.hslColor.lightness)
+ }
+
+ // ---- updateAlpha (Float) ----
+
+ @Test
+ fun updateAlpha() {
+ val state = createState()
+ state.updateAlpha(0.502f)
+ assertEquals(0.502f, state.hslColor.alpha)
+ }
+
+ @Test
+ fun updateAlphaClampsHigh() {
+ val state = createState()
+ state.updateAlpha(2f)
+ assertEquals(1f, state.hslColor.alpha)
+ }
+
+ @Test
+ fun updateAlphaClampsLow() {
+ val state = createState()
+ state.updateAlpha(-0.1f)
+ assertEquals(0f, state.hslColor.alpha)
+ }
+
+ // ---- updateFromHsl ----
+
+ @Test
+ fun updateFromHsl() {
+ val state = createState()
+ val newColor = HslColor(hue = 120f, saturation = 0.6f, lightness = 0.4f, alpha = 0.784f)
+ state.updateFromHsl(newColor)
+ assertEquals(newColor, state.hslColor)
+ }
+
+ // ---- updateFromRgb ----
+
+ @Test
+ fun updateFromRgb() {
+ val state = createState()
+ state.updateFromRgb(RgbColor(1f, 0f, 0f))
+ assertNear(0f, state.hslColor.hue, msg = "hue")
+ assertNear(1f, state.hslColor.saturation, msg = "saturation")
+ assertNear(0.5f, state.hslColor.lightness, msg = "lightness")
+ }
+
+ @Test
+ fun updateFromRgbPreservesAlpha() {
+ val state = createState()
+ state.updateFromRgb(RgbColor(1f, 0f, 0f, alpha = 0.392f))
+ assertEquals(0.392f, state.hslColor.alpha)
+ }
+
+ // ---- updateFromCmyk ----
+
+ @Test
+ fun updateFromCmyk() {
+ val state = createState()
+ state.updateFromCmyk(CmykColor(0f, 0f, 0f, 0f)) // white
+ assertNear(1f, state.hslColor.lightness, msg = "lightness")
+ }
+
+ @Test
+ fun updateFromCmykPreservesAlpha() {
+ val state = createState()
+ state.updateFromCmyk(CmykColor(0f, 0f, 0f, 0f, alpha = 0.196f))
+ assertEquals(0.196f, state.hslColor.alpha)
+ }
+
+ // ---- updateFromLab ----
+
+ @Test
+ fun updateFromLab() {
+ val state = createState()
+ state.updateFromLab(LabColor(l = 0f, a = 0f, b = 0f)) // black
+ assertNear(0f, state.hslColor.lightness, tolerance = 0.01f, msg = "lightness")
+ }
+
+ @Test
+ fun updateFromLabPreservesAlpha() {
+ val state = createState()
+ state.updateFromLab(LabColor(l = 50f, a = 0f, b = 0f, alpha = 0.67f))
+ assertEquals(0.67f, state.hslColor.alpha)
+ }
+
+ // ---- updateFromArgbInt ----
+
+ @Test
+ fun updateFromArgbInt() {
+ val state = createState()
+ state.updateFromArgbInt(0xFFFF0000.toInt()) // red
+ assertNear(0f, state.hslColor.hue, msg = "hue")
+ assertNear(1f, state.hslColor.saturation, msg = "saturation")
+ assertNear(0.5f, state.hslColor.lightness, msg = "lightness")
+ assertNear(1f, state.hslColor.alpha, msg = "alpha")
+ }
+
+ @Test
+ fun updateFromArgbIntWithAlpha() {
+ val state = createState()
+ // Half-transparent green: alpha=128, R=0, G=255, B=0
+ val argb = (128 shl 24) or (0 shl 16) or (255 shl 8) or 0
+ state.updateFromArgbInt(argb)
+ assertNear(120f, state.hslColor.hue, msg = "hue")
+ assertNear(128f / 255f, state.hslColor.alpha, tolerance = 1f / 255f, msg = "alpha")
+ }
+
+ // ---- Derived state ----
+
+ @Test
+ fun derivedRgbColor() {
+ val state = createState(HslColor(hue = 0f, saturation = 1f, lightness = 0.5f))
+ val rgb = state.rgbColor
+ assertNear(1f, rgb.red, msg = "red")
+ assertNear(0f, rgb.green, msg = "green")
+ assertNear(0f, rgb.blue, msg = "blue")
+ }
+
+ @Test
+ fun derivedCmykColor() {
+ val state = createState(HslColor(hue = 0f, saturation = 1f, lightness = 0.5f))
+ val cmyk = state.cmykColor
+ assertNear(0f, cmyk.cyan, msg = "cyan")
+ assertNear(1f, cmyk.magenta, msg = "magenta")
+ assertNear(1f, cmyk.yellow, msg = "yellow")
+ assertNear(0f, cmyk.key, msg = "key")
+ }
+
+ @Test
+ fun derivedLabColor() {
+ val state = createState(HslColor(hue = 0f, saturation = 0f, lightness = 0f))
+ val lab = state.labColor
+ assertNear(0f, lab.l, tolerance = 0.5f, msg = "L")
+ }
+
+ @Test
+ fun derivedArgbInt() {
+ val state = createState(HslColor(hue = 0f, saturation = 1f, lightness = 0.5f))
+ assertEquals(0xFFFF0000.toInt(), state.argbInt)
+ }
+
+ // ---- Update preserves other components ----
+
+ @Test
+ fun updateHuePreservesSaturationAndLightness() {
+ val state = createState(HslColor(hue = 0f, saturation = 0.8f, lightness = 0.6f, alpha = 0.784f))
+ state.updateHue(120f)
+ assertEquals(0.8f, state.hslColor.saturation)
+ assertEquals(0.6f, state.hslColor.lightness)
+ assertEquals(0.784f, state.hslColor.alpha)
+ }
+
+ @Test
+ fun updateAlphaPreservesColor() {
+ val state = createState(HslColor(hue = 100f, saturation = 0.5f, lightness = 0.7f))
+ state.updateAlpha(0.502f)
+ assertEquals(100f, state.hslColor.hue)
+ assertEquals(0.5f, state.hslColor.saturation)
+ assertEquals(0.7f, state.hslColor.lightness)
+ }
+
+ @Test
+ fun updateSaturationPreservesHueAndLightness() {
+ val state = createState(HslColor(hue = 200f, saturation = 0.5f, lightness = 0.6f, alpha = 0.9f))
+ state.updateSaturation(0.3f)
+ assertEquals(200f, state.hslColor.hue)
+ assertEquals(0.6f, state.hslColor.lightness)
+ assertEquals(0.9f, state.hslColor.alpha)
+ }
+
+ @Test
+ fun updateLightnessPreservesHueAndSaturation() {
+ val state = createState(HslColor(hue = 200f, saturation = 0.5f, lightness = 0.6f))
+ state.updateLightness(0.8f)
+ assertEquals(200f, state.hslColor.hue)
+ assertEquals(0.5f, state.hslColor.saturation)
+ }
+
+ // ---- pickerColor (authoritative space) ----
+
+ @Test
+ fun pickerColorReturnsInitialColorUnconverted() {
+ val hsl = HslColor(hue = 123.456f, saturation = 0.789f, lightness = 0.321f, alpha = 0.5f)
+ val state = createState(hsl)
+ assertEquals(hsl, state.pickerColor)
+ }
+
+ @Test
+ fun pickerColorTracksAuthoritativeSpace() {
+ val state = createState()
+ assertIs(state.pickerColor)
+ state.updateRed(0.5f)
+ assertIs(state.pickerColor)
+ state.updateCyan(0.5f)
+ assertIs(state.pickerColor)
+ state.updateLabA(10f)
+ assertIs(state.pickerColor)
+ state.updateHue(180f)
+ assertIs(state.pickerColor)
+ }
+
+ @Test
+ fun updateAlphaPreservesOriginSpace() {
+ val state = createState()
+ state.updateFromRgb(RgbColor(red = 0.2f, green = 0.4f, blue = 0.6f))
+ state.updateAlpha(0.5f)
+ assertIs(state.pickerColor)
+ assertEquals(0.5f, state.pickerColor.alpha)
+ }
+
+ // ---- Zero-drift write-read round-trip per origin space ----
+
+ @Test
+ fun updateFromHslReadsBackTheExactInstance() {
+ val state = createState()
+ val hsl = HslColor(hue = 123.456f, saturation = 0.1f, lightness = 1f / 3f, alpha = 0.7f)
+ state.updateFromHsl(hsl)
+ assertSame(hsl, state.hslColor)
+ assertSame(hsl, state.pickerColor)
+ }
+
+ @Test
+ fun updateFromRgbReadsBackTheExactInstance() {
+ val state = createState()
+ val rgb = RgbColor(red = 0.1f, green = 1f / 3f, blue = 0.7f, alpha = 0.9f)
+ state.updateFromRgb(rgb)
+ assertSame(rgb, state.rgbColor)
+ assertSame(rgb, state.pickerColor)
+ }
+
+ @Test
+ fun updateFromCmykReadsBackTheExactInstance() {
+ val state = createState()
+ val cmyk = CmykColor(cyan = 0.1f, magenta = 1f / 3f, yellow = 0.7f, key = 0.9f, alpha = 0.3f)
+ state.updateFromCmyk(cmyk)
+ assertSame(cmyk, state.cmykColor)
+ assertSame(cmyk, state.pickerColor)
+ }
+
+ @Test
+ fun updateFromLabReadsBackTheExactInstance() {
+ val state = createState()
+ val lab = LabColor(l = 33.333f, a = -12.7f, b = 64.1f, alpha = 0.6f)
+ state.updateFromLab(lab)
+ assertSame(lab, state.labColor)
+ assertSame(lab, state.pickerColor)
+ }
+
+ // ---- Snapshot safety (reads must not write) ----
+
+ @Test
+ fun readInsideReadOnlySnapshotDoesNotThrowAndIsFresh() {
+ val state = createState()
+ state.updateHue(120f)
+ val snapshot = Snapshot.takeSnapshot()
+ try {
+ snapshot.enter {
+ assertEquals(120f, state.hslColor.hue)
+ assertNear(1f, state.rgbColor.green, msg = "green")
+ assertNear(0f, state.rgbColor.red, msg = "red")
+ state.cmykColor
+ state.labColor
+ state.argbInt
+ assertIs(state.pickerColor)
+ }
+ } finally {
+ snapshot.dispose()
+ }
+ }
+
+ // ---- snapshotFlow observation (regression: reads must not write) ----
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun snapshotFlowOnDerivedRgbEmitsAfterUpdateRed() = runTest {
+ // Regression test: observing a derived space via snapshotFlow used to throw
+ // IllegalStateException when reads performed snapshot writes. Collecting an
+ // emission after updateRed proves reads are pure.
+ val state = createState()
+ val emissions = mutableListOf()
+ val job = launch(UnconfinedTestDispatcher(testScheduler)) {
+ snapshotFlow { state.rgbColor }.take(2).toList(emissions)
+ }
+ state.updateRed(0.25f)
+ Snapshot.sendApplyNotifications()
+ job.join()
+ assertEquals(2, emissions.size)
+ assertEquals(1f, emissions[0].red)
+ assertEquals(0.25f, emissions[1].red)
+ }
+
+ // ---- Idempotency (identical write does not invalidate) ----
+
+ @Test
+ fun updateWithIdenticalValueDoesNotInvalidateState() {
+ val state = createState()
+ state.updateRed(0.25f)
+ Snapshot.sendApplyNotifications()
+ val before = state.pickerColor
+ var applyNotifications = 0
+ val observer = Snapshot.registerApplyObserver { changed, _ ->
+ if (changed.isNotEmpty()) applyNotifications++
+ }
+ try {
+ state.updateRed(0.25f)
+ state.updateRed(0.25f)
+ Snapshot.sendApplyNotifications()
+ } finally {
+ observer.dispose()
+ }
+ assertEquals(0, applyNotifications, "identical writes must not produce apply notifications")
+ // Structural-equality policy skips the write entirely, so the authoritative
+ // instance is untouched.
+ assertSame(before, state.pickerColor)
+ }
+
+ // ---- NaN handling ----
+
+ @Test
+ fun updateHueNaNLeavesStateUnchanged() {
+ val state = createState(HslColor(hue = 200f, saturation = 0.8f, lightness = 0.5f))
+ state.updateHue(Float.NaN)
+ assertEquals(200f, state.hslColor.hue)
+ assertEquals(0.8f, state.hslColor.saturation)
+ assertEquals(0.5f, state.hslColor.lightness)
+ }
+
+ @Test
+ fun updateAlphaNaNLeavesStateUnchanged() {
+ val state = createState()
+ state.updateAlpha(Float.NaN)
+ assertEquals(1f, state.hslColor.alpha)
+ }
+
+ // ---- Per-channel RGB updates ----
+
+ @Test
+ fun updateRedClampsHigh() {
+ val state = createState()
+ state.updateRed(2f)
+ assertEquals(1f, state.rgbColor.red)
+ }
+
+ @Test
+ fun updateRedThenGreenPreservesRed() {
+ val state = createState()
+ state.updateRed(0.25f)
+ state.updateGreen(0.5f)
+ assertEquals(0.25f, state.rgbColor.red)
+ assertEquals(0.5f, state.rgbColor.green)
+ }
+
+ @Test
+ fun updateBlueClampsLow() {
+ val state = createState()
+ state.updateBlue(-1f)
+ assertEquals(0f, state.rgbColor.blue)
+ }
+
+ // ---- Per-channel CMYK updates ----
+
+ @Test
+ fun updateCmykChannelsPreserveEachOther() {
+ val state = createState()
+ state.updateCyan(0.1f)
+ state.updateMagenta(0.2f)
+ state.updateYellow(0.3f)
+ state.updateKey(0.4f)
+ assertEquals(0.1f, state.cmykColor.cyan)
+ assertEquals(0.2f, state.cmykColor.magenta)
+ assertEquals(0.3f, state.cmykColor.yellow)
+ assertEquals(0.4f, state.cmykColor.key)
+ }
+
+ // ---- Per-channel LAB updates ----
+
+ @Test
+ fun updateLabChannelsClampAndPreserve() {
+ val state = createState()
+ state.updateLabLightness(150f)
+ state.updateLabA(-200f)
+ state.updateLabB(50f)
+ assertEquals(100f, state.labColor.l)
+ assertEquals(-128f, state.labColor.a)
+ assertEquals(50f, state.labColor.b)
+ }
+
+ // ---- isInteracting ----
+
+ @Test
+ fun isInteractingIsSettableFromInternalCode() {
+ val state = createState()
+ assertFalse(state.isInteracting)
+ state.isInteracting = true
+ assertTrue(state.isInteracting)
+ state.isInteracting = false
+ assertFalse(state.isInteracting)
+ }
+
+ // ---- Saver ----
+
+ private fun saveToArray(state: ColorPickerState): FloatArray =
+ with(ColorPickerStateSaver) {
+ with(SaverScope { true }) {
+ assertNotNull(save(state))
+ }
+ }
+
+ @Test
+ fun saverRoundTripHsl() {
+ val original = ColorPickerState(HslColor(hue = 210f, saturation = 0.4f, lightness = 0.6f, alpha = 0.5f))
+ val restored = assertNotNull(ColorPickerStateSaver.restore(saveToArray(original)))
+ assertIs(restored.pickerColor)
+ assertEquals(original.pickerColor, restored.pickerColor)
+ }
+
+ @Test
+ fun saverRoundTripRgb() {
+ val original = ColorPickerState(RgbColor(red = 0.1f, green = 0.2f, blue = 0.3f, alpha = 0.4f))
+ val restored = assertNotNull(ColorPickerStateSaver.restore(saveToArray(original)))
+ assertIs(restored.pickerColor)
+ assertEquals(original.pickerColor, restored.pickerColor)
+ }
+
+ @Test
+ fun saverRoundTripCmyk() {
+ val original = ColorPickerState(CmykColor(cyan = 0.1f, magenta = 0.2f, yellow = 0.3f, key = 0.4f, alpha = 0.5f))
+ val restored = assertNotNull(ColorPickerStateSaver.restore(saveToArray(original)))
+ assertIs(restored.pickerColor)
+ assertEquals(original.pickerColor, restored.pickerColor)
+ }
+
+ @Test
+ fun saverRoundTripLab() {
+ val original = ColorPickerState(LabColor(l = 42f, a = -30f, b = 60f, alpha = 0.7f))
+ val restored = assertNotNull(ColorPickerStateSaver.restore(saveToArray(original)))
+ assertIs(restored.pickerColor)
+ assertEquals(original.pickerColor, restored.pickerColor)
+ }
+
+ private fun assertBitIdentical(expected: Float, actual: Float, msg: String) {
+ assertEquals(expected.toRawBits(), actual.toRawBits(), "$msg expected=$expected actual=$actual")
+ }
+
+ @Test
+ fun saverRoundTripPreservesExactBitsHsl() {
+ val hsl = HslColor(hue = 123.456f, saturation = 0.1f, lightness = 1f / 3f, alpha = 0.7f)
+ val restored = assertNotNull(ColorPickerStateSaver.restore(saveToArray(ColorPickerState(hsl))))
+ val color = assertIs(restored.pickerColor)
+ assertBitIdentical(hsl.hue, color.hue, "hue")
+ assertBitIdentical(hsl.saturation, color.saturation, "saturation")
+ assertBitIdentical(hsl.lightness, color.lightness, "lightness")
+ assertBitIdentical(hsl.alpha, color.alpha, "alpha")
+ }
+
+ @Test
+ fun saverRoundTripPreservesExactBitsRgb() {
+ val rgb = RgbColor(red = 0.1f, green = 1f / 3f, blue = 0.7f, alpha = 0.9f)
+ val restored = assertNotNull(ColorPickerStateSaver.restore(saveToArray(ColorPickerState(rgb))))
+ val color = assertIs(restored.pickerColor)
+ assertBitIdentical(rgb.red, color.red, "red")
+ assertBitIdentical(rgb.green, color.green, "green")
+ assertBitIdentical(rgb.blue, color.blue, "blue")
+ assertBitIdentical(rgb.alpha, color.alpha, "alpha")
+ }
+
+ @Test
+ fun saverRoundTripPreservesExactBitsCmyk() {
+ val cmyk = CmykColor(cyan = 0.1f, magenta = 1f / 3f, yellow = 0.7f, key = 0.9f, alpha = 0.3f)
+ val restored = assertNotNull(ColorPickerStateSaver.restore(saveToArray(ColorPickerState(cmyk))))
+ val color = assertIs(restored.pickerColor)
+ assertBitIdentical(cmyk.cyan, color.cyan, "cyan")
+ assertBitIdentical(cmyk.magenta, color.magenta, "magenta")
+ assertBitIdentical(cmyk.yellow, color.yellow, "yellow")
+ assertBitIdentical(cmyk.key, color.key, "key")
+ assertBitIdentical(cmyk.alpha, color.alpha, "alpha")
+ }
+
+ @Test
+ fun saverRoundTripPreservesExactBitsLab() {
+ val lab = LabColor(l = 33.333f, a = -12.7f, b = 64.1f, alpha = 0.6f)
+ val restored = assertNotNull(ColorPickerStateSaver.restore(saveToArray(ColorPickerState(lab))))
+ val color = assertIs(restored.pickerColor)
+ assertBitIdentical(lab.l, color.l, "l")
+ assertBitIdentical(lab.a, color.a, "a")
+ assertBitIdentical(lab.b, color.b, "b")
+ assertBitIdentical(lab.alpha, color.alpha, "alpha")
+ }
+
+ @Test
+ fun saverRestoreUnknownSpaceKeyReturnsNull() {
+ assertNull(ColorPickerStateSaver.restore(floatArrayOf(99f, 0f, 0f, 0f, 1f, 0f)))
+ }
+
+ @Test
+ fun saverRestoreOutOfRangeChannelReturnsNull() {
+ // Hue 999 is outside 0..360 — restore must return null, not throw.
+ assertNull(ColorPickerStateSaver.restore(floatArrayOf(0f, 999f, 0.5f, 0.5f, 1f, 0f)))
+ }
+
+ @Test
+ fun saverRestoreWrongSizeReturnsNull() {
+ assertNull(ColorPickerStateSaver.restore(floatArrayOf(0f, 120f)))
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/HueFractionMappingTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/HueFractionMappingTest.kt
new file mode 100644
index 0000000..5d719fb
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/HueFractionMappingTest.kt
@@ -0,0 +1,63 @@
+package codes.side.colorpicker.ui
+
+import codes.side.colorpicker.state.ColorPickerState
+import kotlin.math.nextDown
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Regression tests for the HueSlider value mapping: `HslColor` normalizes hue 360 to
+ * the equivalent 0, so the slider must never write exactly 360 — otherwise dragging
+ * the thumb to the right end of the track would snap it back to the far left.
+ */
+class HueFractionMappingTest {
+
+ @Test
+ fun zeroFractionMapsToZeroHue() {
+ assertEquals(0f, hueFromFraction(0f))
+ }
+
+ @Test
+ fun midFractionMapsProportionally() {
+ assertEquals(180f, hueFromFraction(0.5f))
+ }
+
+ @Test
+ fun fullFractionMapsJustBelow360() {
+ val hue = hueFromFraction(1f)
+ assertTrue(hue < 360f, "hue at the track end must stay below 360, was $hue")
+ assertEquals(360f.nextDown(), hue)
+ }
+
+ @Test
+ fun fullFractionIsNotNormalizedToZeroByState() {
+ // Dragging the M3 Slider to the right edge emits exactly 1.0f; the written
+ // hue must read back unchanged instead of wrapping to 0.
+ val state = ColorPickerState()
+ state.updateHue(hueFromFraction(1f))
+ assertEquals(360f.nextDown(), state.hslColor.hue)
+ }
+
+ @Test
+ fun thumbStaysAtRightEndAfterFullFraction() {
+ val state = ColorPickerState()
+ state.updateHue(hueFromFraction(1f))
+ val thumbFraction = state.hslColor.hue / 360f
+ assertTrue(
+ thumbFraction > 0.9999f && thumbFraction <= 1f,
+ "thumb must stay at the right end, was $thumbFraction",
+ )
+ }
+
+ @Test
+ fun rightEndIsAStablePosition() {
+ // Re-deriving the fraction from the stored hue and writing it back must be a
+ // fixed point, so continued dragging at the right edge does not jitter.
+ val state = ColorPickerState()
+ state.updateHue(hueFromFraction(1f))
+ val redisplayedFraction = state.hslColor.hue / 360f
+ state.updateHue(hueFromFraction(redisplayedFraction))
+ assertEquals(360f.nextDown(), state.hslColor.hue)
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/SliderInteractionGuardTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/SliderInteractionGuardTest.kt
new file mode 100644
index 0000000..d2ed3d7
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/SliderInteractionGuardTest.kt
@@ -0,0 +1,72 @@
+package codes.side.colorpicker.ui
+
+import codes.side.colorpicker.state.ColorPickerState
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Regression tests for [SliderInteractionGuard]: `isInteracting` must never be left
+ * stuck at `true` when a slider leaves composition mid-drag, because M3 Slider does
+ * not invoke `onValueChangeFinished` when its node is disposed during a gesture.
+ */
+class SliderInteractionGuardTest {
+
+ @Test
+ fun beginSetsIsInteracting() {
+ val state = ColorPickerState()
+ val guard = SliderInteractionGuard(state)
+ guard.begin()
+ assertTrue(state.isInteracting)
+ }
+
+ @Test
+ fun endClearsIsInteracting() {
+ val state = ColorPickerState()
+ val guard = SliderInteractionGuard(state)
+ guard.begin()
+ guard.end()
+ assertFalse(state.isInteracting)
+ }
+
+ @Test
+ fun forgottenMidDragClearsIsInteracting() {
+ // Slider removed from composition while a drag is in progress.
+ val state = ColorPickerState()
+ val guard = SliderInteractionGuard(state)
+ guard.begin()
+ guard.onForgotten()
+ assertFalse(state.isInteracting)
+ }
+
+ @Test
+ fun forgottenAfterGestureFinishedDoesNothing() {
+ val state = ColorPickerState()
+ val guard = SliderInteractionGuard(state)
+ guard.begin()
+ guard.end()
+ guard.onForgotten()
+ assertFalse(state.isInteracting)
+ }
+
+ @Test
+ fun forgottenIdleGuardDoesNotClobberAnotherSlidersGesture() {
+ // An idle slider (e.g. AlphaSlider toggled off) leaves composition while a
+ // different slider on the same state is mid-drag: the flag must survive.
+ val state = ColorPickerState()
+ val dragging = SliderInteractionGuard(state)
+ val idle = SliderInteractionGuard(state)
+ dragging.begin()
+ idle.onForgotten()
+ assertTrue(state.isInteracting)
+ }
+
+ @Test
+ fun abandonedMidDragClearsIsInteracting() {
+ val state = ColorPickerState()
+ val guard = SliderInteractionGuard(state)
+ guard.begin()
+ guard.onAbandoned()
+ assertFalse(state.isInteracting)
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/util/RandomColorsTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/util/RandomColorsTest.kt
new file mode 100644
index 0000000..1dcf0f7
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/util/RandomColorsTest.kt
@@ -0,0 +1,73 @@
+package codes.side.colorpicker.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class RandomColorsTest {
+
+ @Test
+ fun randomHslColorInRange() {
+ repeat(50) {
+ val color = randomHslColor()
+ assertTrue(color.hue in 0f..360f, "Hue out of range: ${color.hue}")
+ assertTrue(color.saturation in 0f..1f, "Saturation out of range: ${color.saturation}")
+ assertTrue(color.lightness in 0f..1f, "Lightness out of range: ${color.lightness}")
+ assertEquals(1f, color.alpha)
+ }
+ }
+
+ @Test
+ fun randomHslColorPure() {
+ repeat(50) {
+ val color = randomHslColor(pure = true)
+ assertTrue(color.hue in 0f..360f, "Hue out of range: ${color.hue}")
+ assertEquals(1f, color.saturation)
+ assertEquals(0.5f, color.lightness)
+ assertEquals(1f, color.alpha)
+ }
+ }
+
+ @Test
+ fun randomHslColorProducesVariation() {
+ val colors = (1..20).map { randomHslColor() }
+ val uniqueHues = colors.map { it.hue }.toSet()
+ assertTrue(uniqueHues.size > 1, "Expected variation in hues")
+ }
+
+ @Test
+ fun randomHslColorReturnsFloatBasedHslColor() {
+ val color = randomHslColor()
+ // Verify this is genuinely float-based: hue should typically not be a round integer
+ // (probability of exact integer is vanishingly small with Random.nextFloat() * 360)
+ // Just verify the type compiles and fields are accessible as Float
+ val _h: Float = color.hue
+ val _s: Float = color.saturation
+ val _l: Float = color.lightness
+ val _a: Float = color.alpha
+ assertTrue(_h >= 0f)
+ assertTrue(_s >= 0f)
+ assertTrue(_l >= 0f)
+ assertTrue(_a >= 0f)
+ }
+
+ @Test
+ fun randomHslColorPureVariation() {
+ val colors = (1..20).map { randomHslColor(pure = true) }
+ val uniqueHues = colors.map { it.hue }.toSet()
+ assertTrue(uniqueHues.size > 1, "Expected variation in pure-mode hues")
+ // All should have sat=1, light=0.5
+ colors.forEach {
+ assertEquals(1f, it.saturation)
+ assertEquals(0.5f, it.lightness)
+ }
+ }
+
+ @Test
+ fun randomHslColorDefaultAlpha() {
+ repeat(10) {
+ val color = randomHslColor()
+ assertEquals(1f, color.alpha, "Default alpha should be 1f (opaque)")
+ }
+ }
+}
diff --git a/docs/index.md b/docs/index.md
index e2b5e6f..015e696 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,192 +1,226 @@
-# andColorPicker — Color Picker library for Android
+# andColorPicker — Compose Multiplatform Color Picker
-:avocado: Handy, :snake: flexible, and :zap: lightning-fast Android color picker views and utilities.
-
-
+:avocado: Handy, :snake: flexible, and :zap: lightning-fast color picker components for Compose Multiplatform — Android, iOS, and Desktop (JVM).
## :pill: Features
-- Clean, easy-to-use components and API
-- High performance
-- Material styling in mind
-- Standard Android SDK view family
-- Wide color models support
-- Tooling and utilities
+- Compose Multiplatform (Android, iOS, Desktop/JVM)
+- Material 3 theming via `ColorPickerDefaults`
+- HSL, RGB, CMYK, and LAB color models
- Alpha channel support
-- Cutting edge tech stack
-- Active development and support
+- Zero-drift editing via `ColorPickerState` origin tracking
+- Unidirectional data flow — one state object, many components
+- Hex string parsing and formatting
+- Ready-made color picker dialog
+- Accessibility semantics and RTL layout support
## :hammer: Setup
-Gradle dependency:
-
-```gradle
-implementation "codes.side:andcolorpicker:0.6.2"
+```kotlin
+// build.gradle.kts
+implementation("codes.side:colorpicker:1.0.0")
```
-## :art: Picker types
+In a Kotlin Multiplatform project, add it to `commonMain`:
-### HSL (hue, saturation, lightness)
+```kotlin
+kotlin {
+ sourceSets {
+ commonMain.dependencies {
+ implementation("codes.side:colorpicker:1.0.0")
+ }
+ }
+}
+```
-- *Add color model description*
+Published targets: `android`, `jvm`, `iosArm64`, `iosSimulatorArm64`.
-
+## :rocket: Quick Start
-#### Layout XML Snippet
+```kotlin
+@Composable
+fun MyScreen() {
+ val state = rememberColorPickerState(
+ initialColor = HslColor(hue = 200f, saturation = 0.8f, lightness = 0.5f)
+ )
-Basic HSL components:
-```xml
-
+ Column {
+ HslColorPicker(state = state)
+ ColorSwatch(
+ color = state.hslColor.toComposeColor(),
+ modifier = Modifier.size(48.dp)
+ )
+ }
+}
```
-Supported `hslMode` values:
-- `hue` (default)
-- `saturation`
-- `lightness`
-
-Supported `hslColoringMode` values:
-- `pure` (default)
-- `output`
-
-Alpha component:
-```xml
-
-```
+## :art: Components
+
+### Full Pickers
+
+One composable per color model, stacking that model's channel sliders plus an optional alpha slider:
-#### Kotlin Snippet
```kotlin
-// Configure color model programmatically
-hueSeekBar.mode = Mode.MODE_HUE // Mode.MODE_SATURATION, Mode.MODE_LIGHTNESS
-
-// Configure coloring mode programmatically
-hueSeekBar.coloringMode = ColoringMode.PURE_COLOR // ColoringMode.OUTPUT_COLOR
-
-// Group pickers with PickerGroup to automatically synchronize color across them
-val group = PickerGroup().also {
- it.registerPickers(
- hueSeekBar,
- saturationSeekBar,
- lightnessSeekBar,
- alphaSeekBar
- )
-}
+val state = rememberColorPickerState()
-// Get current color immediately
-Log.d(
- TAG,
- "Current color is ${hueSeekBar.pickedColor}"
-)
+HslColorPicker(state = state, showAlpha = true)
+RgbColorPicker(state = state, showAlpha = true)
+CmykColorPicker(state = state, showAlpha = true)
+LabColorPicker(state = state, showAlpha = true)
+```
-// Listen individual pickers or groups for changes
-group.addListener(
- object : HSLColorPickerSeekBar.DefaultOnColorPickListener() {
- override fun onColorChanged(
- picker: ColorSeekBar,
- color: IntegerHSLColor,
- value: Int
- ) {
- Log.d(
- TAG,
- "$color picked"
- )
- swatchView.setSwatchColor(
- color
- )
- }
- }
-)
+Every full picker and channel slider accepts a `coloringMode` (`AlphaSlider` has none — it always previews the actual color):
-// Set desired color programmatically
-group.setColor(
- IntegerHSLColor().also {
- it.setFromColorInt(
- Color.rgb(
- 28,
- 84,
- 187
- )
- )
- }
-)
+- `ColoringMode.Independent` — each slider's gradient shows the channel's full range, independent of the other channels
+- `ColoringMode.Contextual` — each slider's gradient previews the actual resulting color at every position
-// Set color components programmatically
-hueSeekBar.progress = 50
+### Individual Sliders
+
+Compose any subset of channel sliders against a shared state:
+
+```kotlin
+// HSL
+HueSlider(state = state)
+SaturationSlider(state = state)
+LightnessSlider(state = state)
+
+// RGB
+RedSlider(state = state)
+GreenSlider(state = state)
+BlueSlider(state = state)
+
+// CMYK
+CyanSlider(state = state)
+MagentaSlider(state = state)
+YellowSlider(state = state)
+KeySlider(state = state)
+
+// LAB
+LightnessLabSlider(state = state)
+LabASlider(state = state)
+LabBSlider(state = state)
+
+// Alpha (works with any origin space)
+AlphaSlider(state = state)
```
-### RGB (red, green, blue)
+Sliders expose `label` and `valueLabel` slots plus `semanticLabel` / `semanticValueText` accessibility parameters.
-
+### Color Swatch
-#### Properties
+Renders a color over a transparency checkerboard:
-- View name: ```RGBColorPickerSeekBar```
-- ```app:rgbMode``` for RGB component selection
+```kotlin
+ColorSwatch(
+ color = state.hslColor.toComposeColor(),
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ contentDescription = "Selected color",
+)
+```
-### LAB
+### Dialog
-
+A Material 3 `AlertDialog` with an HSL picker and a live swatch:
-#### Properties
+```kotlin
+ColorPickerDialog(
+ onColorSelected = { hsl -> /* confirmed color */ },
+ onDismiss = { /* close */ },
+ initialColor = HslColor(hue = 200f, saturation = 0.8f, lightness = 0.5f),
+ title = "Pick a Color",
+ confirmText = "Select",
+ dismissText = "Cancel",
+ showAlpha = true,
+)
+```
-- View name: ```LABColorPickerSeekBar```
-- ```app:labMode``` for LAB component selection
+### Theming
-### CMYK (cyan, magenta, yellow, key)
+Colors and shapes default to `MaterialTheme` and can be overridden per component:
-
+```kotlin
+HslColorPicker(
+ state = state,
+ colors = ColorPickerDefaults.colors(
+ checkerboardLight = Color.White,
+ checkerboardDark = Color.LightGray,
+ ),
+ shapes = ColorPickerDefaults.shapes(
+ trackShape = RoundedCornerShape(4.dp),
+ swatchShape = RoundedCornerShape(8.dp),
+ ),
+)
+```
-#### Properties
+## :gear: State & Conversions
-- View name: ```CMYKColorPickerSeekBar```
-- ```app:cmykMode``` for CMYK component selection
-- ```app:cmykColoringMode``` for coloring mode selection
+`ColorPickerState` is the single source of truth. It stores the color in whichever space was last written to (the *origin*) and derives the other spaces on demand — so editing in a space and reading it back is always exact, and no precision drift accumulates.
-Supported `cmykMode` values:
-- `cyan` (default)
-- `magenta`
-- `yellow`
-- `black`
+```kotlin
+val state = rememberColorPickerState()
+
+// Read any color space
+state.hslColor
+state.rgbColor
+state.cmykColor
+state.labColor
+state.argbInt
+state.pickerColor // the authoritative color in its origin space
+
+// Per-channel updates (NaN ignored, values clamped)
+state.updateHue(180f)
+state.updateRed(1f)
+state.updateCyan(0.3f)
+state.updateLabLightness(50f)
+state.updateAlpha(0.5f)
+
+// Whole-color updates (the written space becomes the origin)
+state.updateFromHsl(HslColor(hue = 0f, saturation = 1f, lightness = 0.5f))
+state.updateFromRgb(RgbColor(1f, 0f, 0f))
+state.updateFromArgbInt(0xFFFF0000.toInt())
+```
-Supported `cmykColoringMode` values:
-- `pure` (default)
-- `output`
+Use `rememberSaveableColorPickerState()` to keep the state across configuration changes and process death on platforms with saved-instance-state support (primarily Android).
-### Swatches
+Standalone conversions are float-based extension functions:
-SwatchView component:
-```xml
-
+```kotlin
+val rgb = HslColor(hue = 0f, saturation = 1f, lightness = 0.5f).toRgb()
+val cmyk = rgb.toCmyk()
+val lab = rgb.toLab()
+val composeColor = rgb.toComposeColor()
+val backToHsl = composeColor.toHslColor()
+
+// Hex strings
+rgb.toHexString() // "#FFFF0000"
+rgb.toHexString(includeAlpha = false) // "#FF0000"
+"#3380CC".toRgbColorOrNull() // RgbColor, or null on invalid input
```
-#### Kotlin Snippet:
-```kotlin
-swatchView.setSwatchPatternTint(
- Color.LTGRAY
-)
+## :truck: Migrating from andcolorpicker (0.6.x)
-swatchView.setSwatchColor(
- IntegerHSLColor().also {
- it.setFromColorInt(
- ColorUtils.setAlphaComponent(
- Color.MAGENTA,
- 128
- )
- )
- }
-)
+The View-based `codes.side:andcolorpicker` artifact (XML `HSLColorPickerSeekBar` and friends) is discontinued. This library is a full Compose Multiplatform rewrite published under new coordinates:
+
+```diff
+- implementation("codes.side:andcolorpicker:0.6.2")
++ implementation("codes.side:colorpicker:1.0.0")
```
+There is no 1:1 API mapping — migrate by concept:
+
+| andcolorpicker (View-based) | colorpicker (Compose) |
+|----------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|
+| `HSLColorPickerSeekBar` (`hslMode` = hue/saturation/lightness) | `HueSlider` / `SaturationSlider` / `LightnessSlider`, or `HslColorPicker` for all three |
+| `RGBColorPickerSeekBar` | `RedSlider` / `GreenSlider` / `BlueSlider`, or `RgbColorPicker` |
+| `CMYKColorPickerSeekBar` | `CyanSlider` / `MagentaSlider` / `YellowSlider` / `KeySlider`, or `CmykColorPicker` |
+| `LABColorPickerSeekBar` | `LightnessLabSlider` / `LabASlider` / `LabBSlider`, or `LabColorPicker` |
+| `HSLAlphaColorPickerSeekBar` | `AlphaSlider` |
+| `PickerGroup` + `registerPickers` | Pass one `ColorPickerState` to every component — they stay in sync automatically |
+| `SwatchView` | `ColorSwatch` |
+| `OnColorPickListener` / `addListener` | Read `state.hslColor` (or any other space) — it is Compose snapshot state; use `snapshotFlow` outside composition |
+| `IntegerHSLColor` and friends | `HslColor`, `RgbColor`, `CmykColor`, `LabColor` (float-based, with `fromInt` factories) |
+| `hslColoringMode` = `pure` / `output` | `ColoringMode.Independent` / `ColoringMode.Contextual` |
+
## :memo: License
```
diff --git a/gradle.properties b/gradle.properties
index 23339e0..5f97cb5 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,21 +1,8 @@
-# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
-# http://www.gradle.org/docs/current/userguide/build_environment.html
-# Specifies the JVM arguments used for the daemon process.
-# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx1536m
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
-# AndroidX package structure to make it clearer which packages are bundled with the
-# Android operating system, and which are packaged with your app's APK
-# https://developer.android.com/topic/libraries/support-library/androidx-rn
+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.caching=true
android.useAndroidX=true
-# Automatically convert third-party libraries to use AndroidX
-android.enableJetifier=true
-# Kotlin code style for this project: "official" or "obsolete":
+android.nonTransitiveRClass=true
kotlin.code.style=official
+# Dokka Gradle plugin v2 mode (DGPv2); required opt-in for the Dokka 2.x plugin.
+org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..baa28d1
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,13 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
+toolchainVendor=JETBRAINS
+toolchainVersion=21
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..44f7b66
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,33 @@
+[versions]
+kotlin = "2.4.0"
+agp = "9.2.1"
+compose-multiplatform = "1.11.1"
+compose-material3 = "1.9.0"
+androidx-activity-compose = "1.13.0"
+androidx-lifecycle = "2.11.0"
+kotlinx-coroutines = "1.11.0"
+kotlinx-collections-immutable = "0.5.1"
+binary-compatibility-validator = "0.18.1"
+dokka = "2.2.0"
+
+[libraries]
+compose-runtime = { group = "org.jetbrains.compose.runtime", name = "runtime", version.ref = "compose-multiplatform" }
+compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "compose-multiplatform" }
+compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "compose-multiplatform" }
+compose-material3 = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "compose-material3" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
+kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
+kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" }
+kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
+
+[plugins]
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
+composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+androidKmpLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" }
+dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index f6b961f..b1b8ef5 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 44d3c0f..a351597 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,7 @@
-#Sun Apr 18 18:54:01 EEST 2021
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip
diff --git a/gradlew b/gradlew
index cccdd3d..249efbb 100755
--- a/gradlew
+++ b/gradlew
@@ -1,78 +1,128 @@
-#!/usr/bin/env sh
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# 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
+#
+# https://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.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
-##
-## Gradle start up script for UN*X
-##
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
##############################################################################
# Attempt to set APP_HOME
+
# Resolve links: $0 may be a link
-PRG="$0"
-# Need this for relative symlinks.
-while [ -h "$PRG" ] ; do
- ls=`ls -ld "$PRG"`
- link=`expr "$ls" : '.*-> \(.*\)$'`
- if expr "$link" : '/.*' > /dev/null; then
- PRG="$link"
- else
- PRG=`dirname "$PRG"`"/$link"
- fi
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
done
-SAVED="`pwd`"
-cd "`dirname \"$PRG\"`/" >/dev/null
-APP_HOME="`pwd -P`"
-cd "$SAVED" >/dev/null
-
-APP_NAME="Gradle"
-APP_BASE_NAME=`basename "$0"`
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS=""
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD="maximum"
+MAX_FD=maximum
warn () {
echo "$*"
-}
+} >&2
die () {
echo
echo "$*"
echo
exit 1
-}
+} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
-case "`uname`" in
- CYGWIN* )
- cygwin=true
- ;;
- Darwin* )
- darwin=true
- ;;
- MINGW* )
- msys=true
- ;;
- NONSTOP* )
- nonstop=true
- ;;
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
- JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACMD=$JAVA_HOME/jre/sh/java
else
- JAVACMD="$JAVA_HOME/bin/java"
+ JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -81,92 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
- JAVACMD="java"
- which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
+ fi
fi
# Increase the maximum file descriptors if we can.
-if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
- MAX_FD_LIMIT=`ulimit -H -n`
- if [ $? -eq 0 ] ; then
- if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
- MAX_FD="$MAX_FD_LIMIT"
- fi
- ulimit -n $MAX_FD
- if [ $? -ne 0 ] ; then
- warn "Could not set maximum file descriptor limit: $MAX_FD"
- fi
- else
- warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
- fi
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
fi
-# For Darwin, add options to specify how the application appears in the dock
-if $darwin; then
- GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
-fi
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
-# For Cygwin, switch paths to Windows format before running java
-if $cygwin ; then
- APP_HOME=`cygpath --path --mixed "$APP_HOME"`
- CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
- JAVACMD=`cygpath --unix "$JAVACMD"`
-
- # We build the pattern for arguments to be converted via cygpath
- ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
- SEP=""
- for dir in $ROOTDIRSRAW ; do
- ROOTDIRS="$ROOTDIRS$SEP$dir"
- SEP="|"
- done
- OURCYGPATTERN="(^($ROOTDIRS))"
- # Add a user-defined pattern to the cygpath arguments
- if [ "$GRADLE_CYGPATTERN" != "" ] ; then
- OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
- fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
- i=0
- for arg in "$@" ; do
- CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
- CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
-
- if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
- eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
- else
- eval `echo args$i`="\"$arg\""
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
fi
- i=$((i+1))
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
done
- case $i in
- (0) set -- ;;
- (1) set -- "$args0" ;;
- (2) set -- "$args0" "$args1" ;;
- (3) set -- "$args0" "$args1" "$args2" ;;
- (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
- (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
- (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
- (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
- (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
- (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
- esac
fi
-# Escape application args
-save () {
- for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
- echo " "
-}
-APP_ARGS=$(save "$@")
-
-# Collect all arguments for the java command, following the shell quoting and substitution rules
-eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
-# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
-if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
- cd "$(dirname "$0")"
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
fi
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
index f955316..a51ec4f 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -1,84 +1,82 @@
-@if "%DEBUG%" == "" @echo off
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
-@rem Gradle startup script for Windows
+@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS=
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto init
+if %ERRORLEVEL% equ 0 goto execute
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
-goto fail
+"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-if exist "%JAVA_EXE%" goto init
+if exist "%JAVA_EXE%" goto execute
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
-goto fail
-
-:init
-@rem Get command-line arguments, handling Windows variants
-
-if not "%OS%" == "Windows_NT" goto win9xME_args
-
-:win9xME_args
-@rem Slurp the command line arguments.
-set CMD_LINE_ARGS=
-set _SKIP=2
-
-:win9xME_args_slurp
-if "x%~1" == "x" goto execute
-
-set CMD_LINE_ARGS=%*
+"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
-
-:end
-@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
+@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
-:omega
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/integrationapp/.gitignore b/integrationapp/.gitignore
deleted file mode 100644
index 796b96d..0000000
--- a/integrationapp/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build
diff --git a/integrationapp/build.gradle b/integrationapp/build.gradle
deleted file mode 100644
index 040457c..0000000
--- a/integrationapp/build.gradle
+++ /dev/null
@@ -1,52 +0,0 @@
-apply plugin: "com.android.application"
-apply plugin: "kotlin-android"
-apply plugin: "kotlin-android-extensions"
-
-android {
- compileSdkVersion 30
- buildToolsVersion "29.0.3"
-
- defaultConfig {
- applicationId "codes.side.andcolorpicker.integrationapp"
- minSdkVersion 19
- targetSdkVersion 30
- versionCode 1
- versionName "1.0"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- }
- lintOptions {
- abortOnError false
- }
- signingConfigs {
- release {
- storeFile file(System.getenv("KEYSTORE_FILE") ?: KEYSTORE_FILE)
- storePassword System.getenv("KEYSTORE_PASSWORD") ?: KEYSTORE_PASSWORD
- keyAlias System.getenv("KEY_ALIAS") ?: KEY_ALIAS
- keyPassword System.getenv("KEY_PASSWORD") ?: KEY_PASSWORD
- }
- }
- buildTypes {
- release {
- signingConfig signingConfigs.release
- minifyEnabled true
- proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
- }
- }
-
-}
-
-dependencies {
- implementation fileTree(dir: "libs", include: ["*.jar"])
-
- implementation "codes.side:andcolorpicker:0.6.2"
-
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
- implementation "androidx.appcompat:appcompat:1.3.0-rc01"
- implementation "androidx.core:core-ktx:1.6.0-alpha02"
- implementation "androidx.constraintlayout:constraintlayout:2.0.4"
-
- testImplementation "junit:junit:4.13.2"
- androidTestImplementation "androidx.test.ext:junit:1.1.2"
- androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0"
-}
diff --git a/integrationapp/proguard-rules.pro b/integrationapp/proguard-rules.pro
deleted file mode 100644
index f1b4245..0000000
--- a/integrationapp/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
diff --git a/integrationapp/src/androidTest/java/codes/side/andcolorpicker/integrationapp/ExampleInstrumentedTest.kt b/integrationapp/src/androidTest/java/codes/side/andcolorpicker/integrationapp/ExampleInstrumentedTest.kt
deleted file mode 100644
index db52f1c..0000000
--- a/integrationapp/src/androidTest/java/codes/side/andcolorpicker/integrationapp/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package codes.side.andcolorpicker.integrationapp
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.assertEquals
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals(
- "codes.side.andcolorpicker.integrationapp",
- appContext.packageName
- )
- }
-}
diff --git a/integrationapp/src/main/AndroidManifest.xml b/integrationapp/src/main/AndroidManifest.xml
deleted file mode 100644
index bbeb99b..0000000
--- a/integrationapp/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/integrationapp/src/main/java/codes/side/andcolorpicker/integrationapp/MainActivity.kt b/integrationapp/src/main/java/codes/side/andcolorpicker/integrationapp/MainActivity.kt
deleted file mode 100644
index aad0768..0000000
--- a/integrationapp/src/main/java/codes/side/andcolorpicker/integrationapp/MainActivity.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package codes.side.andcolorpicker.integrationapp
-
-import androidx.appcompat.app.AppCompatActivity
-
-class MainActivity : AppCompatActivity(R.layout.activity_main)
diff --git a/integrationapp/src/main/res/layout/activity_main.xml b/integrationapp/src/main/res/layout/activity_main.xml
deleted file mode 100644
index e98fa95..0000000
--- a/integrationapp/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/integrationapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/integrationapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index 016bcb8..0000000
--- a/integrationapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/integrationapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/integrationapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
deleted file mode 100644
index 016bcb8..0000000
--- a/integrationapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/integrationapp/src/main/res/mipmap-hdpi/ic_launcher.png b/integrationapp/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index 8c697ac..0000000
Binary files a/integrationapp/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/integrationapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
deleted file mode 100644
index 59b5b70..0000000
Binary files a/integrationapp/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-hdpi/ic_launcher_round.png b/integrationapp/src/main/res/mipmap-hdpi/ic_launcher_round.png
deleted file mode 100644
index 5aa2d81..0000000
Binary files a/integrationapp/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-mdpi/ic_launcher.png b/integrationapp/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index 09d9cc2..0000000
Binary files a/integrationapp/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/integrationapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
deleted file mode 100644
index 9368c0c..0000000
Binary files a/integrationapp/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-mdpi/ic_launcher_round.png b/integrationapp/src/main/res/mipmap-mdpi/ic_launcher_round.png
deleted file mode 100644
index cbf5c75..0000000
Binary files a/integrationapp/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-xhdpi/ic_launcher.png b/integrationapp/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index de161c2..0000000
Binary files a/integrationapp/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/integrationapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 3b80b82..0000000
Binary files a/integrationapp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/integrationapp/src/main/res/mipmap-xhdpi/ic_launcher_round.png
deleted file mode 100644
index 95ba016..0000000
Binary files a/integrationapp/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-xxhdpi/ic_launcher.png b/integrationapp/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 3d6362b..0000000
Binary files a/integrationapp/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/integrationapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 2378142..0000000
Binary files a/integrationapp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/integrationapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
deleted file mode 100644
index faef069..0000000
Binary files a/integrationapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/integrationapp/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index 6346ad9..0000000
Binary files a/integrationapp/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/integrationapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 3a43be2..0000000
Binary files a/integrationapp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/integrationapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/integrationapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
deleted file mode 100644
index f9b8d88..0000000
Binary files a/integrationapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/integrationapp/src/main/res/values/colors.xml b/integrationapp/src/main/res/values/colors.xml
deleted file mode 100644
index 3ec6675..0000000
--- a/integrationapp/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- #6200EE
- #3700B3
- #03DAC5
-
diff --git a/integrationapp/src/main/res/values/ic_launcher_background.xml b/integrationapp/src/main/res/values/ic_launcher_background.xml
deleted file mode 100644
index 99cea76..0000000
--- a/integrationapp/src/main/res/values/ic_launcher_background.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- #FFFFFF
-
diff --git a/integrationapp/src/main/res/values/strings.xml b/integrationapp/src/main/res/values/strings.xml
deleted file mode 100644
index 3e8fb2a..0000000
--- a/integrationapp/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
- andColorPicker Integration Sample
-
diff --git a/integrationapp/src/main/res/values/styles.xml b/integrationapp/src/main/res/values/styles.xml
deleted file mode 100644
index bfacc6f..0000000
--- a/integrationapp/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
diff --git a/integrationapp/src/test/java/codes/side/andcolorpicker/integrationapp/ExampleUnitTest.kt b/integrationapp/src/test/java/codes/side/andcolorpicker/integrationapp/ExampleUnitTest.kt
deleted file mode 100644
index 9c5708f..0000000
--- a/integrationapp/src/test/java/codes/side/andcolorpicker/integrationapp/ExampleUnitTest.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package codes.side.andcolorpicker.integrationapp
-
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(
- 4,
- 2 + 2
- )
- }
-}
diff --git a/sample/androidApp/build.gradle.kts b/sample/androidApp/build.gradle.kts
new file mode 100644
index 0000000..ad2d77c
--- /dev/null
+++ b/sample/androidApp/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.composeCompiler)
+}
+
+android {
+ namespace = "codes.side.colorpicker.sample"
+ compileSdk = 37
+
+ defaultConfig {
+ applicationId = "codes.side.colorpicker.sample"
+ minSdk = 24
+ targetSdk = 37
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation(project(":sample:shared"))
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+}
diff --git a/sample/androidApp/src/main/AndroidManifest.xml b/sample/androidApp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8e71667
--- /dev/null
+++ b/sample/androidApp/src/main/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/androidApp/src/main/kotlin/codes/side/colorpicker/sample/MainActivity.kt b/sample/androidApp/src/main/kotlin/codes/side/colorpicker/sample/MainActivity.kt
new file mode 100644
index 0000000..a1f210e
--- /dev/null
+++ b/sample/androidApp/src/main/kotlin/codes/side/colorpicker/sample/MainActivity.kt
@@ -0,0 +1,16 @@
+package codes.side.colorpicker.sample
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ SampleApp()
+ }
+ }
+}
diff --git a/sample/iosApp/iosApp/ContentView.swift b/sample/iosApp/iosApp/ContentView.swift
new file mode 100644
index 0000000..4f56190
--- /dev/null
+++ b/sample/iosApp/iosApp/ContentView.swift
@@ -0,0 +1,18 @@
+import UIKit
+import SwiftUI
+import ComposeApp
+
+struct ComposeView: UIViewControllerRepresentable {
+ func makeUIViewController(context: Context) -> UIViewController {
+ MainViewControllerKt.MainViewController()
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+}
+
+struct ContentView: View {
+ var body: some View {
+ ComposeView()
+ .ignoresSafeArea(.keyboard)
+ }
+}
diff --git a/sample/iosApp/iosApp/iOSApp.swift b/sample/iosApp/iosApp/iOSApp.swift
new file mode 100644
index 0000000..927e0b9
--- /dev/null
+++ b/sample/iosApp/iosApp/iOSApp.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+@main
+struct iOSApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts
new file mode 100644
index 0000000..78b4dd2
--- /dev/null
+++ b/sample/shared/build.gradle.kts
@@ -0,0 +1,42 @@
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+ alias(libs.plugins.androidKmpLibrary)
+}
+
+kotlin {
+ android {
+ namespace = "codes.side.colorpicker.sample.shared"
+ compileSdk = 37
+ minSdk = 24
+
+ @OptIn(ExperimentalKotlinGradlePluginApi::class)
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+ }
+
+ listOf(
+ iosArm64(),
+ iosSimulatorArm64(),
+ ).forEach { iosTarget ->
+ iosTarget.binaries.framework {
+ baseName = "ComposeApp"
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(project(":colorpicker"))
+ implementation(libs.compose.runtime)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.ui)
+ }
+ }
+}
diff --git a/sample/shared/src/commonMain/kotlin/codes/side/colorpicker/sample/SampleApp.kt b/sample/shared/src/commonMain/kotlin/codes/side/colorpicker/sample/SampleApp.kt
new file mode 100644
index 0000000..b2fbcf8
--- /dev/null
+++ b/sample/shared/src/commonMain/kotlin/codes/side/colorpicker/sample/SampleApp.kt
@@ -0,0 +1,233 @@
+package codes.side.colorpicker.sample
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import codes.side.colorpicker.conversion.toComposeColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.state.rememberSaveableColorPickerState
+import codes.side.colorpicker.ui.AlphaSlider
+import codes.side.colorpicker.ui.BlueSlider
+import codes.side.colorpicker.ui.ColorPickerDialog
+import codes.side.colorpicker.ui.ColorSwatch
+import codes.side.colorpicker.ui.CyanSlider
+import codes.side.colorpicker.ui.GreenSlider
+import codes.side.colorpicker.ui.HueSlider
+import codes.side.colorpicker.ui.KeySlider
+import codes.side.colorpicker.ui.LabASlider
+import codes.side.colorpicker.ui.LabBSlider
+import codes.side.colorpicker.ui.LightnessLabSlider
+import codes.side.colorpicker.ui.LightnessSlider
+import codes.side.colorpicker.ui.MagentaSlider
+import codes.side.colorpicker.ui.RedSlider
+import codes.side.colorpicker.ui.SaturationSlider
+import codes.side.colorpicker.ui.YellowSlider
+import codes.side.colorpicker.util.randomHslColor
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SampleApp() {
+ val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
+ MaterialTheme(colorScheme = colorScheme) {
+ val state = rememberSaveableColorPickerState(
+ initialColor = HslColor(hue = 200f, saturation = 0.8f, lightness = 0.5f),
+ )
+ var showDialog by rememberSaveable { mutableStateOf(false) }
+ var coloringMode by rememberSaveable { mutableStateOf(ColoringMode.Contextual) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Color Picker") },
+ colors = TopAppBarDefaults.topAppBarColors(),
+ )
+ },
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Live preview swatch
+ item {
+ ColorSwatch(
+ color = state.hslColor.toComposeColor(),
+ modifier = Modifier.fillMaxWidth().height(80.dp),
+ )
+ }
+
+ // Coloring mode switcher
+ item {
+ SingleChoiceSegmentedButtonRow(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ val modes = ColoringMode.entries
+ modes.forEachIndexed { index, mode ->
+ SegmentedButton(
+ selected = coloringMode == mode,
+ onClick = { coloringMode = mode },
+ shape = SegmentedButtonDefaults.itemShape(
+ index = index,
+ count = modes.size,
+ ),
+ label = { Text(mode.name) },
+ )
+ }
+ }
+ }
+
+ // Color values readout
+ item {
+ val hsl = state.hslColor
+ val rgb = state.rgbColor
+ val cmyk = state.cmykColor
+ val lab = state.labColor
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "#${
+ state.argbInt
+ .toUInt()
+ .toString(16)
+ .uppercase()
+ .padStart(8, '0')
+ }",
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = { state.updateFromHsl(randomHslColor()) }) {
+ Text("Random")
+ }
+ Button(onClick = { showDialog = true }) {
+ Text("Dialog")
+ }
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+ val h = hsl.intHue.pad(3)
+ val s = hsl.intSaturation.pad(3)
+ val l = hsl.intLightness.pad(3)
+ val ha = hsl.intAlpha.pad(3)
+ val r = rgb.intRed.pad(3)
+ val g = rgb.intGreen.pad(3)
+ val b = rgb.intBlue.pad(3)
+ val ra = rgb.intAlpha.pad(3)
+ val c = cmyk.intCyan.pad(3)
+ val m = cmyk.intMagenta.pad(3)
+ val y = cmyk.intYellow.pad(3)
+ val k = cmyk.intKey.pad(3)
+ val ll = lab.intL.pad(3)
+ val la = lab.intA.pad(3)
+ val lb = lab.intB.pad(3)
+ Readout("HSL H:$h S:$s L:$l A:$ha")
+ Readout("RGB R:$r G:$g B:$b A:$ra")
+ Readout("CMYK C:$c M:$m Y:$y K:$k")
+ Readout("LAB L:$ll a:$la b:$lb")
+ }
+
+ // HSL section
+ item { SectionHeader("HSL") }
+ item { HueSlider(state = state, coloringMode = coloringMode) }
+ item { SaturationSlider(state = state, coloringMode = coloringMode) }
+ item { LightnessSlider(state = state, coloringMode = coloringMode) }
+
+ item { HorizontalDivider() }
+
+ // RGB section
+ item { SectionHeader("RGB") }
+ item { RedSlider(state = state, coloringMode = coloringMode) }
+ item { GreenSlider(state = state, coloringMode = coloringMode) }
+ item { BlueSlider(state = state, coloringMode = coloringMode) }
+
+ item { HorizontalDivider() }
+
+ // CMYK section
+ item { SectionHeader("CMYK") }
+ item { CyanSlider(state = state, coloringMode = coloringMode) }
+ item { MagentaSlider(state = state, coloringMode = coloringMode) }
+ item { YellowSlider(state = state, coloringMode = coloringMode) }
+ item { KeySlider(state = state, coloringMode = coloringMode) }
+
+ item { HorizontalDivider() }
+
+ // LAB section
+ item { SectionHeader("LAB") }
+ item { LightnessLabSlider(state = state, coloringMode = coloringMode) }
+ item { LabASlider(state = state, coloringMode = coloringMode) }
+ item { LabBSlider(state = state, coloringMode = coloringMode) }
+
+ item { HorizontalDivider() }
+
+ // Alpha section
+ item { SectionHeader("Alpha") }
+ item { AlphaSlider(state = state) }
+ }
+ }
+
+ if (showDialog) {
+ ColorPickerDialog(
+ onColorSelected = { color ->
+ state.updateFromHsl(color)
+ showDialog = false
+ },
+ onDismiss = { showDialog = false },
+ initialColor = state.hslColor,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SectionHeader(title: String) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ )
+}
+
+@Composable
+private fun Readout(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ )
+}
+
+private fun Int.pad(width: Int): String = toString().padStart(width)
diff --git a/sample/shared/src/iosMain/kotlin/codes/side/colorpicker/sample/MainViewController.kt b/sample/shared/src/iosMain/kotlin/codes/side/colorpicker/sample/MainViewController.kt
new file mode 100644
index 0000000..1f666c2
--- /dev/null
+++ b/sample/shared/src/iosMain/kotlin/codes/side/colorpicker/sample/MainViewController.kt
@@ -0,0 +1,5 @@
+package codes.side.colorpicker.sample
+
+import androidx.compose.ui.window.ComposeUIViewController
+
+fun MainViewController() = ComposeUIViewController { SampleApp() }
diff --git a/scripts/publish-mavencentral.gradle b/scripts/publish-mavencentral.gradle
deleted file mode 100644
index cfc93c2..0000000
--- a/scripts/publish-mavencentral.gradle
+++ /dev/null
@@ -1,109 +0,0 @@
-apply plugin: "maven-publish"
-apply plugin: "signing"
-
-task androidSourcesJar(type: Jar) {
- archiveClassifier.set("sources")
- if (project.plugins.findPlugin("com.android.library")) {
- from android.sourceSets.main.java.srcDirs
- from android.sourceSets.main.kotlin.srcDirs
- } else {
- from sourceSets.main.java.srcDirs
- from sourceSets.main.kotlin.srcDirs
- }
-}
-
-artifacts {
- archives androidSourcesJar
-}
-
-group = PUBLISH_GROUP_ID
-version = PUBLISH_VERSION
-
-ext["signing.keyId"] = ""
-ext["signing.password"] = ""
-ext["signing.secretKeyRingFile"] = ""
-ext["ossrhUsername"] = ""
-ext["ossrhPassword"] = ""
-ext["sonatypeStagingProfileId"] = ""
-
-File secretPropsFile = project.rootProject.file("local.properties")
-if (secretPropsFile.exists()) {
- Properties p = new Properties()
- p.load(new FileInputStream(secretPropsFile))
- p.each { name, value ->
- ext[name] = value
- }
-} else {
- ext["signing.keyId"] = System.getenv("SIGNING_KEY_ID")
- ext["signing.password"] = System.getenv("SIGNING_PASSWORD")
- ext["signing.secretKeyRingFile"] = System.getenv("SIGNING_SECRET_KEY_RING_FILE")
- ext["ossrhUsername"] = System.getenv("OSSRH_USERNAME")
- ext["ossrhPassword"] = System.getenv("OSSRH_PASSWORD")
- ext["sonatypeStagingProfileId"] = System.getenv("SONATYPE_STAGING_PROFILE_ID")
-}
-
-afterEvaluate {
- publishing {
- publications {
- release(MavenPublication) {
- groupId PUBLISH_GROUP_ID
- artifactId PUBLISH_ARTIFACT_ID
- version PUBLISH_VERSION
- if (project.plugins.findPlugin("com.android.library")) {
- from components.release
- } else {
- artifact("$buildDir/libs/${project.getName()}-${version}.jar")
- }
-
- artifact androidSourcesJar
-
- pom {
- name = PUBLISH_ARTIFACT_ID
- description = "Color picker library for Android"
- url = "https://github.com/side-codes/andColorPicker"
- licenses {
- license {
- name = "The Apache License, Version 2.0"
- url = "http://www.apache.org/licenses/LICENSE-2.0.txt"
- }
- }
- developers {
- developer {
- id = "dummyco"
- name = "Illia Achour"
- email = "ilyaachour@gmail.com"
- }
- developer {
- id = "N7k"
- name = "Maksim Novik"
- email = "nvk.mse@gmail.com"
- }
- }
- scm {
- connection = "scm:git:github.com/side-codes/andColorPicker.git"
- developerConnection = "scm:git:ssh://github.com/side-codes/andColorPicker.git"
- url = "https://github.com/side-codes/andColorPicker/tree/master"
- }
- }
- }
- }
- repositories {
- maven {
- name = "sonatype"
-
- def releasesRepoUrl = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/"
- def snapshotsRepoUrl = "https://s01.oss.sonatype.org/content/repositories/snapshots/"
- url = version.endsWith("SNAPSHOT") ? snapshotsRepoUrl : releasesRepoUrl
-
- credentials {
- username ossrhUsername
- password ossrhPassword
- }
- }
- }
- }
-}
-
-signing {
- sign publishing.publications
-}
diff --git a/settings.gradle b/settings.gradle
deleted file mode 100644
index 206568a..0000000
--- a/settings.gradle
+++ /dev/null
@@ -1,4 +0,0 @@
-rootProject.name='AndColorPicker Sample'
-include ':app'
-include ':andcolorpicker'
-include ':integrationapp'
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..0618636
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,36 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
+
+dependencyResolutionManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ }
+}
+
+rootProject.name = "andColorPicker"
+
+include(":colorpicker")
+include(":sample:shared")
+include(":sample:androidApp")