diff --git a/README.md b/README.md index bd38344..6a68d4f 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Una librería de componentes de UI altamente personalizables para **Jetpack Comp - 🎨 **Personalización avanzada**: Control total sobre colores, tamaños y formas - 🧩 **Basado en Material 3**: Integración nativa con el sistema de diseño de Material - ⚡ **Fácil de usar**: API intuitiva y compatible con los componentes existentes -- 🖌️ **Tinte selectivo por capa (`tintCap`)**: Pinta solo las capas que quieras de un `ImageVector` y preserva el resto +- 🖌️ **Tinte selectivo por capa (`tintCap` y `tintStroke`)**: Pinta solo las capas que quieras de un `ImageVector` (relleno **y/o** trazo) y preserva el resto - 🧪 **Cubierto por tests**: Suite de tests unitarios (JVM) e instrumentados (Compose UI tests) - 📱 **Compatible con API 24+**: Soporte para una amplia gama de dispositivos - 🚀 **Release automatizado**: Pipeline de CI que publica AAR + release + JitPack al mergear a `master` @@ -162,7 +162,9 @@ Wrapper sobre `androidx.compose.material3.Icon` que añade el parámetro `tintCa | `contentDescription` | `String?` | Descripción para accesibilidad | | `modifier` | `Modifier` | Modificador estándar | | `tint` | `Color` | Color a aplicar (por defecto `LocalContentColor.current`) | -| `tintCap` | `TintCap` | Alcance del tint (ver tabla abajo, por defecto `TintCap.All`) | +| `tintCap` | `TintCap` | Alcance del tint del **relleno** (ver tabla abajo, por defecto `TintCap.All`) | +| `tintStroke` | `Color?` | Color del **trazo** a aplicar (opcional, por defecto `null`) | +| `tintStrokeCap` | `TintStroke` | Alcance del tint del **trazo** (por defecto `TintStroke.All`) | **Variantes de `TintCap`:** | Variante | Descripción | @@ -229,14 +231,73 @@ El módulo incluye un `ImageVector` de camión multi-capa pensado para ejercitar Índice 3 → cargo (caja de carga) #43A047 ``` -Úsalo para prototipar y validar el comportamiento de `tintCap` sin necesidad de un asset externo: +Cada capa además tiene un trazo por defecto con color distintivo para que puedas ejercitar `tintStroke`: + +``` +Índice 0 → wheels (trazo) #212121 +Índice 1 → body (trazo) #B71C1C +Índice 2 → cab (trazo) #0D47A1 +Índice 3 → cargo (trazo) #1B5E20 +``` + +Úsalo para prototipar y validar el comportamiento de `tintCap` y `tintStroke` sin necesidad de un asset externo: ```kotlin IconComponents( imageVector = Icons.MapTruck, contentDescription = "Truck", tint = Color.Yellow, - tintCap = TintCap.layers(0, 3) // solo neumáticos y carga en amarillo + tintCap = TintCap.layers(0, 3), // solo neumáticos y carga en amarillo + tintStroke = Color(0xFF00BCD4), // trazo cyan en todas las capas + tintStrokeCap = TintStroke.All +) +``` + +#### Tinte selectivo de **trazo** (`tintStroke` + `tintStrokeCap`) + +Además de controlar el color del **relleno** con `tintCap`, `IconComponents` admite `tintStroke` para controlar el color del **trazo** (stroke) de cada capa de forma independiente. + +- `tintStroke: Color?` — color del trazo. Cuando es `null` (default) no se transforma el trazo del vector. +- `tintStrokeCap: TintStroke` — qué capas reciben el color del trazo, con las mismas variantes que `TintCap`: + | Variante | Descripción | + |----------|-------------| + | `TintStroke.All` | Recolorrea **todas** las capas (default) | + | `TintStroke.Undefined` | **No aplica** ninguna transformación; el trazo se conserva | + | `TintStroke.index(n)` | Recolorrea **solo** la capa top-level en el índice `n` | + | `TintStroke.range(rango)` | Recolorrea **todas** las capas cuyo índice esté dentro del rango | + | `TintStroke.layers(1, 3)` | Recolorrea **solo** las capas top-level en los índices indicados | + +> 💡 **¿Por qué?** Un mismo vector puede tener relleno de marca (que quieres preservar) y trazos que sí deben personalizarse (color de acento, modo oscuro, estados hover, etc.). Con `tintStroke`/`tintStrokeCap` puedes controlar el color del trazo capa por capa, igual que con `tintCap`/`TintCap`. + +**Ejemplos:** + +```kotlin +// Recolorear el trazo de TODAS las capas con un color de acento +IconComponents( + imageVector = Icons.MapTruck, + contentDescription = "Truck", + tint = Color.Yellow, + tintCap = TintCap.Undefined, // relleno intacto + tintStroke = Color(0xFF00BCD4), // trazo cyan + tintStrokeCap = TintStroke.All +) + +// Cambiar el color del trazo SOLO de los neumáticos y la carga +IconComponents( + imageVector = Icons.MapTruck, + contentDescription = "Truck", + tintStroke = Color.Red, + tintStrokeCap = TintStroke.layers(0, 3) +) + +// Combinar relleno y trazo sobre capas diferentes +IconComponents( + imageVector = Icons.MapTruck, + contentDescription = "Truck", + tint = Color(0xFF4CAF50), // relleno verde solo en ruedas y carga + tintCap = TintCap.layers(0, 3), + tintStroke = Color(0xFFFF9800), // trazo naranja solo en chasis y cabina + tintStrokeCap = TintStroke.layers(1, 2) ) ``` @@ -257,7 +318,9 @@ Wrapper sobre `androidx.compose.foundation.Image` con la misma potencia de `tint | `alpha` | `Float` | Opacidad (default `DefaultAlpha`) | | `colorFilter` | `ColorFilter?` | Filtro de color opcional adicional | | `tint` | `Color?` | Color a aplicar (opcional) | -| `tintCap` | `TintCap` | Alcance del tint (default `TintCap.Undefined`) | +| `tintCap` | `TintCap` | Alcance del tint del **relleno** (default `TintCap.Undefined`) | +| `tintStroke` | `Color?` | Color del **trazo** a aplicar (opcional) | +| `tintStrokeCap` | `TintStroke` | Alcance del tint del **trazo** (default `TintStroke.All`) | **Ejemplo de uso:** @@ -289,6 +352,54 @@ ImageComponents( ) ``` +#### Tinte selectivo de **trazo** (`tintStroke` + `tintStrokeCap`) + +Además de controlar el color del **relleno** con `tintCap`, los componentes `IconComponents` e `ImageComponents` admiten `tintStroke` para controlar el color del **trazo** (stroke) de cada capa de forma independiente. + +- `tintStroke: Color?` — color del trazo. Cuando es `null` (default) no se transforma el trazo del vector. +- `tintStrokeCap: TintStroke` — qué capas reciben el color del trazo, con las mismas variantes que `TintCap`: + | Variante | Descripción | + |----------|-------------| + | `TintStroke.All` | Recolorrea **todas** las capas (default) | + | `TintStroke.Undefined` | **No aplica** ninguna transformación; el trazo se conserva | + | `TintStroke.index(n)` | Recolorrea **solo** la capa top-level en el índice `n` | + | `TintStroke.range(rango)` | Recolorrea **todas** las capas cuyo índice esté dentro del rango | + | `TintStroke.layers(1, 3)` | Recolorrea **solo** las capas top-level en los índices indicados | + +> 💡 **¿Por qué?** Un mismo vector puede tener relleno de marca (que quieres preservar) y trazos que sí deben personalizarse (color de acento, modo oscuro, estados hover, etc.). Con `tintStroke`/`tintStrokeCap` puedes controlar el color del trazo capa por capa, igual que con `tintCap`/`TintCap`. + +**Ejemplos:** + +```kotlin +// Recolorear el trazo de TODAS las capas con un color de acento +IconComponents( + imageVector = Icons.MapTruck, + contentDescription = "Truck", + tint = Color.Yellow, + tintCap = TintCap.Undefined, // relleno intacto + tintStroke = Color(0xFF00BCD4), // trazo cyan + tintStrokeCap = TintStroke.All +) + +// Cambiar el color del trazo SOLO de los neumáticos y la carga +IconComponents( + imageVector = Icons.MapTruck, + contentDescription = "Truck", + tintStroke = Color.Red, + tintStrokeCap = TintStroke.layers(0, 3) +) + +// Combinar relleno y trazo sobre capas diferentes +IconComponents( + imageVector = Icons.MapTruck, + contentDescription = "Truck", + tint = Color(0xFF4CAF50), // relleno verde solo en ruedas y carga + tintCap = TintCap.layers(0, 3), + tintStroke = Color(0xFFFF9800), // trazo naranja solo en chasis y cabina + tintStrokeCap = TintStroke.layers(1, 2) +) +``` + --- ## 🎨 Sistema de Colores @@ -332,9 +443,10 @@ Cada componente está cubierto por tests. Para ejecutarlos: | `LinearProgressIndicatorComponents` | — | — | | `RangeSliderComponent` | — | — | | `TintCap` | ✅ 9 tests | ✅ vía `Icon` / `Image` | -| `ImageVectorTinter` | ✅ 7 tests | ✅ vía `Icon` / `Image` | -| `IconComponents` (con `tintCap`) | — | ✅ 6 tests | -| `ImageComponents` (con `tintCap`) | — | ✅ 5 tests | +| `TintStroke` | ✅ 9 tests | ✅ vía `Icon` / `Image` | +| `ImageVectorTinter` | ✅ 7 tests + 6 stroke | ✅ vía `Icon` / `Image` | +| `IconComponents` (con `tintCap` / `tintStroke`) | — | ✅ 6 tests + 7 stroke | +| `ImageComponents` (con `tintCap` / `tintStroke`) | — | ✅ 5 tests + 6 stroke | Los UI tests renderizan el fixture `Icons.MapTruck` (4 capas top-level con colores distinguibles) y muestrean píxeles del bitmap capturado para verificar que cada variante de `tintCap` pinta exactamente las capas correctas. diff --git a/build.gradle.kts b/build.gradle.kts index bc03463..47c9222 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,4 @@ plugins { alias(libs.plugins.android.library) apply false } -version = "0.1.11" +version = "0.1.20" diff --git a/component/src/androidTest/java/com/blipblipcode/component/image/IconTintStrokeTest.kt b/component/src/androidTest/java/com/blipblipcode/component/image/IconTintStrokeTest.kt new file mode 100644 index 0000000..3aed78b --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/image/IconTintStrokeTest.kt @@ -0,0 +1,155 @@ +package com.blipblipcode.component.image + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [IconComponents] with the [tintStroke] parameter using the + * [Icons.MapTruck] fixture. Each layer has a distinctive default stroke colour so we can + * verify that only the layers targeted by [tintStrokeCap] receive the new stroke colour. + */ +class IconTintStrokeTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "icon-stroke-under-test" + private val iconSizeDp = 128.dp + + private val strokeTintColor = Color(0xFFFF00FF) // magenta + + // Default stroke colours of Icons.MapTruck — see MapTruck.kt + private val wheelsStrokeColor = Color(0xFF212121) + private val bodyStrokeColor = Color(0xFFB71C1C) + private val cabStrokeColor = Color(0xFF0D47A1) + private val cargoStrokeColor = Color(0xFF1B5E20) + + /** + * Renders the truck wrapped in an [IconComponents] with the given stroke configuration + * and samples pixels at well-known stroke positions. + * + * Coordinates are chosen to land exactly on the stroke centre of each layer so the + * captured pixel reflects the stroke colour rather than the fill colour or background. + */ + private fun renderAndSampleStroke( + tint: Color = Color.Unspecified, + tintCap: TintCap = TintCap.Undefined, + strokeTint: Color? = strokeTintColor, + strokeCap: TintStroke = TintStroke.All, + ): IntArray { + composeTestRule.setContent { + Surface(modifier = Modifier.background(Color.White)) { + Box( + modifier = Modifier + .size(iconSizeDp) + .background(Color.White) + .testTag(testTagValue) + ) { + IconComponents( + imageVector = Icons.MapTruck, + contentDescription = null, + modifier = Modifier.size(iconSizeDp), + tint = tint, + tintCap = tintCap, + tintStroke = strokeTint, + tintStrokeCap = strokeCap, + ) + } + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w * 17 / 64, h * 54 / 64), // right edge of front tire (wheels stroke) + bmp.getPixel(w * 32 / 64, h * 44 / 64), // top edge of body chassis + bmp.getPixel(w * 51 / 64, h * 14 / 64), // top edge of cab shell + bmp.getPixel(w * 20 / 64, h * 4 / 64) // top edge of cargo + ) + } + + @Test + fun stroke_null_preserves_original_stroke_colors() { + val px = renderAndSampleStroke(strokeTint = null) + assertEquals(wheelsStrokeColor.toArgb(), px[0]) + assertEquals(bodyStrokeColor.toArgb(), px[1]) + assertEquals(cabStrokeColor.toArgb(), px[2]) + assertEquals(cargoStrokeColor.toArgb(), px[3]) + } + + @Test + fun stroke_all_recolors_every_layer_stroke() { + val px = renderAndSampleStroke(strokeCap = TintStroke.All) + assertEquals(strokeTintColor.toArgb(), px[0]) + assertEquals(strokeTintColor.toArgb(), px[1]) + assertEquals(strokeTintColor.toArgb(), px[2]) + assertEquals(strokeTintColor.toArgb(), px[3]) + } + + @Test + fun stroke_index_recolors_only_the_matching_layer_stroke() { + val px = renderAndSampleStroke(strokeCap = TintStroke.index(2)) + assertEquals(wheelsStrokeColor.toArgb(), px[0]) + assertEquals(bodyStrokeColor.toArgb(), px[1]) + assertEquals(strokeTintColor.toArgb(), px[2]) + assertEquals(cargoStrokeColor.toArgb(), px[3]) + } + + @Test + fun stroke_range_recolors_only_layers_inside_the_range() { + val px = renderAndSampleStroke(strokeCap = TintStroke.range(0..1)) + assertEquals(strokeTintColor.toArgb(), px[0]) + assertEquals(strokeTintColor.toArgb(), px[1]) + assertEquals(cabStrokeColor.toArgb(), px[2]) + assertEquals(cargoStrokeColor.toArgb(), px[3]) + } + + @Test + fun stroke_layers_recolors_only_the_specified_positions() { + val px = renderAndSampleStroke(strokeCap = TintStroke.layers(0, 3)) + assertEquals(strokeTintColor.toArgb(), px[0]) + assertEquals(bodyStrokeColor.toArgb(), px[1]) + assertEquals(cabStrokeColor.toArgb(), px[2]) + assertEquals(strokeTintColor.toArgb(), px[3]) + } + + @Test + fun stroke_undefined_preserves_original_stroke_colors_even_when_strokeTint_is_set() { + val px = renderAndSampleStroke(strokeCap = TintStroke.Undefined) + assertEquals(wheelsStrokeColor.toArgb(), px[0]) + assertEquals(bodyStrokeColor.toArgb(), px[1]) + assertEquals(cabStrokeColor.toArgb(), px[2]) + assertEquals(cargoStrokeColor.toArgb(), px[3]) + } + + @Test + fun fill_and_stroke_tints_can_be_combined_on_different_layers() { + // Fill tint on layers 0 and 3 (wheels + cargo), stroke tint on layers 1 and 2 (body + cab). + val px = renderAndSampleStroke( + tint = Color(0xFF00FF00), + tintCap = TintCap.layers(0, 3), + strokeTint = Color(0xFF00FFFF), + strokeCap = TintStroke.layers(1, 2), + ) + // Stroke pixels reflect the new stroke colour on layers 1 and 2 only. + assertEquals(wheelsStrokeColor.toArgb(), px[0]) // wheels: stroke untouched + assertEquals(Color(0xFF00FFFF).toArgb(), px[1]) // body: stroke recolored + assertEquals(Color(0xFF00FFFF).toArgb(), px[2]) // cab: stroke recolored + assertEquals(cargoStrokeColor.toArgb(), px[3]) // cargo: stroke untouched + } +} \ No newline at end of file diff --git a/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintStrokeTest.kt b/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintStrokeTest.kt new file mode 100644 index 0000000..99fc4c5 --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintStrokeTest.kt @@ -0,0 +1,127 @@ +package com.blipblipcode.component.image + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [ImageComponents] with the [tintStroke] parameter using the + * [Icons.MapTruck] fixture. Mirrors [IconTintStrokeTest] for the Image composable. + */ +class ImageTintStrokeTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "image-stroke-under-test" + private val imageSizeDp = 128.dp + + private val strokeTintColor = Color(0xFFFF00FF) // magenta + + private val wheelsStrokeColor = Color(0xFF212121) + private val bodyStrokeColor = Color(0xFFB71C1C) + private val cabStrokeColor = Color(0xFF0D47A1) + private val cargoStrokeColor = Color(0xFF1B5E20) + + private fun renderAndSampleStroke( + tint: Color? = null, + tintCap: TintCap = TintCap.Undefined, + strokeTint: Color? = strokeTintColor, + strokeCap: TintStroke = TintStroke.All, + ): IntArray { + composeTestRule.setContent { + Box( + modifier = Modifier + .size(imageSizeDp) + .background(Color.White) + .testTag(testTagValue) + ) { + ImageComponents( + imageVector = Icons.MapTruck, + contentDescription = null, + modifier = Modifier.size(imageSizeDp), + tint = tint, + tintCap = tintCap, + tintStroke = strokeTint, + tintStrokeCap = strokeCap, + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w * 17 / 64, h * 54 / 64), // right edge of front tire (wheels stroke) + bmp.getPixel(w * 32 / 64, h * 44 / 64), // top edge of body chassis + bmp.getPixel(w * 51 / 64, h * 14 / 64), // top edge of cab shell + bmp.getPixel(w * 20 / 64, h * 4 / 64) // top edge of cargo + ) + } + + @Test + fun stroke_null_preserves_original_stroke_colors() { + val px = renderAndSampleStroke(strokeTint = null) + assertEquals(wheelsStrokeColor.toArgb(), px[0]) + assertEquals(bodyStrokeColor.toArgb(), px[1]) + assertEquals(cabStrokeColor.toArgb(), px[2]) + assertEquals(cargoStrokeColor.toArgb(), px[3]) + } + + @Test + fun stroke_all_recolors_every_layer_stroke() { + val px = renderAndSampleStroke(strokeCap = TintStroke.All) + assertEquals(strokeTintColor.toArgb(), px[0]) + assertEquals(strokeTintColor.toArgb(), px[1]) + assertEquals(strokeTintColor.toArgb(), px[2]) + assertEquals(strokeTintColor.toArgb(), px[3]) + } + + @Test + fun stroke_index_recolors_only_the_matching_layer_stroke() { + val px = renderAndSampleStroke(strokeCap = TintStroke.index(2)) + assertEquals(wheelsStrokeColor.toArgb(), px[0]) + assertEquals(bodyStrokeColor.toArgb(), px[1]) + assertEquals(strokeTintColor.toArgb(), px[2]) + assertEquals(cargoStrokeColor.toArgb(), px[3]) + } + + @Test + fun stroke_range_recolors_only_layers_inside_the_range() { + val px = renderAndSampleStroke(strokeCap = TintStroke.range(0..1)) + assertEquals(strokeTintColor.toArgb(), px[0]) + assertEquals(strokeTintColor.toArgb(), px[1]) + assertEquals(cabStrokeColor.toArgb(), px[2]) + assertEquals(cargoStrokeColor.toArgb(), px[3]) + } + + @Test + fun stroke_layers_recolors_only_the_specified_positions() { + val px = renderAndSampleStroke(strokeCap = TintStroke.layers(0, 3)) + assertEquals(strokeTintColor.toArgb(), px[0]) + assertEquals(bodyStrokeColor.toArgb(), px[1]) + assertEquals(cabStrokeColor.toArgb(), px[2]) + assertEquals(strokeTintColor.toArgb(), px[3]) + } + + @Test + fun stroke_undefined_preserves_original_stroke_colors_even_when_strokeTint_is_set() { + val px = renderAndSampleStroke(strokeCap = TintStroke.Undefined) + assertEquals(wheelsStrokeColor.toArgb(), px[0]) + assertEquals(bodyStrokeColor.toArgb(), px[1]) + assertEquals(cabStrokeColor.toArgb(), px[2]) + assertEquals(cargoStrokeColor.toArgb(), px[3]) + } +} \ No newline at end of file diff --git a/component/src/main/java/com/blipblipcode/component/image/Icon.kt b/component/src/main/java/com/blipblipcode/component/image/Icon.kt index 690e748..ee87642 100644 --- a/component/src/main/java/com/blipblipcode/component/image/Icon.kt +++ b/component/src/main/java/com/blipblipcode/component/image/Icon.kt @@ -9,11 +9,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector /** - * A thin wrapper around Material 3's [MaterialIcon] that adds [tintCap] support for vector - * drawables. [tintCap] controls which layers of the [imageVector] receive the [tint] color; - * the rest are rendered with their original colors. + * A thin wrapper around Material 3's [MaterialIcon] that adds [tintCap] and [tintStroke] + * support for vector drawables. + * + * - [tintCap] controls which layers of the [imageVector] receive the fill [tint] color; the + * rest are rendered with their original fill colors. + * - [tintStroke] is the optional color used to recolor the stroke of the layers selected by + * [tintStrokeCap]. When `null` (default) the vector's original strokes are preserved. * * @see TintCap + * @see TintStroke */ @Composable fun IconComponents( @@ -22,12 +27,19 @@ fun IconComponents( modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, tintCap: TintCap = TintCap.All, + tintStroke: Color? = null, + tintStrokeCap: TintStroke = TintStroke.All, ) { - val recolored: ImageVector? = remember(imageVector, tint, tintCap) { + val recolored: ImageVector? = remember(imageVector, tint, tintCap, tintStroke, tintStrokeCap) { + val fillNeedsRebuild = !tintCap.isUndefined && tintCap !== TintCap.All + // Material Icon's `tint` only recolors the fill. To recolor the stroke we always + // need to rebuild the vector, even when tintStrokeCap is All. + val strokeNeedsRebuild = tintStroke != null && !tintStrokeCap.isUndefined + when { - tintCap.isUndefined -> null - tintCap === TintCap.All -> null - else -> recolorImageVector(imageVector, tint, tintCap) + fillNeedsRebuild || strokeNeedsRebuild -> + recolorImageVector(imageVector, tint, tintCap, tintStroke, tintStrokeCap) + else -> null } } val effectiveTint: Color = when { diff --git a/component/src/main/java/com/blipblipcode/component/image/Image.kt b/component/src/main/java/com/blipblipcode/component/image/Image.kt index ccde74d..6d9edd9 100644 --- a/component/src/main/java/com/blipblipcode/component/image/Image.kt +++ b/component/src/main/java/com/blipblipcode/component/image/Image.kt @@ -12,18 +12,28 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.graphics.DefaultAlpha /** - * A wrapper around Compose Foundation's [FoundationImage] that adds [tintCap] support for - * vector drawables. [tintCap] controls which layers of the [imageVector] receive the [tint] - * color; the rest are rendered with their original colors. + * A wrapper around Compose Foundation's [FoundationImage] that adds [tintCap] and + * [tintStroke] support for vector drawables. * - * - When [tint] is `null` no tint is applied (standard behavior). + * - [tintCap] controls which layers of the [imageVector] receive the fill [tint] color; the + * rest are rendered with their original fill colors. + * - [tintStroke] is the optional color used to recolor the stroke of the layers selected by + * [tintStrokeCap]. When `null` (default) the vector's original strokes are preserved. + * + * Fill behavior: + * - When [tint] is `null` no fill tint is applied (standard behavior). * - When [tint] is non-null and [tintCap] is [TintCap.Undefined], the tint is ignored and the - * vector's original colors are preserved. - * - When [tint] is non-null and [tintCap] is [TintCap.All], the tint is applied to every + * vector's original fill colors are preserved. + * - When [tint] is non-null and [tintCap] is [TintCap.All], the fill tint is applied to every * layer using [ColorFilter.tint]. * - When [tint] is non-null and [tintCap] is [TintCap.Index], [TintCap.Range] or * [TintCap.Layers], the vector is rebuilt so only the matching layers are tinted and * [ColorFilter] is left untouched. + * + * Stroke behavior: + * - When [tintStroke] is `null`, the vector's original strokes are preserved. + * - When [tintStroke] is non-null the vector is rebuilt so the strokes of the layers matched by + * [tintStrokeCap] are recolored. */ @Composable fun ImageComponents( @@ -36,15 +46,27 @@ fun ImageComponents( colorFilter: ColorFilter? = null, tint: Color? = null, tintCap: TintCap = TintCap.Undefined, + tintStroke: Color? = null, + tintStrokeCap: TintStroke = TintStroke.All, ) { - val recolored: ImageVector? = remember(imageVector, tint, tintCap) { - if (tint == null || tintCap.isUndefined || tintCap === TintCap.All) { - null + val recolored: ImageVector? = remember( + imageVector, tint, tintCap, tintStroke, tintStrokeCap + ) { + val needsFillRebuild = tint != null && + !tintCap.isUndefined && + tintCap !== TintCap.All + val needsStrokeRebuild = tintStroke != null && !tintStrokeCap.isUndefined + + if (needsFillRebuild || needsStrokeRebuild) { + recolorImageVector(imageVector, tint ?: Color.Unspecified, tintCap, tintStroke, tintStrokeCap) } else { - recolorImageVector(imageVector, tint, tintCap) + null } } + + val strokeNeedsRebuild = tintStroke != null && !tintStrokeCap.isUndefined val effectiveColorFilter: ColorFilter? = when { + strokeNeedsRebuild -> colorFilter tint == null -> colorFilter tintCap.isUndefined -> colorFilter tintCap === TintCap.All -> colorFilter ?: ColorFilter.tint(tint) diff --git a/component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt b/component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt index 76c8604..c15f5f3 100644 --- a/component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt +++ b/component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt @@ -10,22 +10,33 @@ import androidx.compose.ui.graphics.vector.VectorPath import androidx.compose.ui.graphics.vector.group /** - * Rebuilds [source] into a new [ImageVector] applying [tint] only to the layers matched by - * [tintCap]. Layers that do not match keep their original colors. + * Rebuilds [source] into a new [ImageVector] applying [tint] only to the fill of the layers + * matched by [tintCap], and [strokeTint] only to the stroke of the layers matched by + * [strokeTintCap]. Layers (or strokes) that do not match keep their original colors. * - * When [tintCap] is [TintCap.All] (the default for [Icon]) the source vector is returned - * untouched and tinting is expected to be applied externally via the standard - * `tint` parameter — this avoids rebuilding the vector when not needed. + * Fill rules: + * - When [tintCap] is [TintCap.Undefined] the fill is left untouched. + * - When [tintCap] is [TintCap.All] every layer's fill is set to [tint]. + * - Otherwise only the matching layers' fills are recolored. * - * When [tintCap] is [TintCap.Undefined] (the default for [Image]) the source vector is - * returned untouched and no tint is applied at any level. + * Stroke rules: + * - When [strokeTint] is `null` the stroke is left untouched. + * - When [strokeTint] is non-null and [strokeTintCap] is [TintStroke.All] every layer's stroke + * is set to [strokeTint]. + * - When [strokeTint] is non-null and [strokeTintCap] is [TintStroke.Undefined] the stroke is + * left untouched. + * - Otherwise only the matching layers' strokes are recolored. */ internal fun recolorImageVector( source: ImageVector, tint: Color, - tintCap: TintCap + tintCap: TintCap, + strokeTint: Color? = null, + strokeTintCap: TintStroke = TintStroke.All, ): ImageVector { - if (tintCap.isUndefined) return source + if (tintCap.isUndefined && (strokeTint == null || strokeTintCap.isUndefined)) { + return source + } val builder = ImageVector.Builder( name = source.name, @@ -36,12 +47,23 @@ internal fun recolorImageVector( ) val tintBrush: Brush = SolidColor(tint) + val strokeBrush: Brush? = strokeTint?.let { SolidColor(it) } + val recolorFill = !tintCap.isUndefined + val recolorStroke = strokeBrush != null && !strokeTintCap.isUndefined // Top-level nodes form the layer-index space. Iterate as a snapshot to be safe. val topLevel = source.root.toNodeList() topLevel.forEachIndexed { index, node -> - val shouldTint = tintCap.appliesTo(index) - copyNode(builder, node, tintBrush, shouldTint) + val shouldTintFill = recolorFill && tintCap.appliesTo(index) + val shouldTintStroke = recolorStroke && strokeTintCap.appliesTo(index) + copyNode( + builder = builder, + node = node, + tintBrush = tintBrush, + shouldTintFill = shouldTintFill, + strokeBrush = strokeBrush, + shouldTintStroke = shouldTintStroke + ) } return builder.build() @@ -51,11 +73,27 @@ private fun copyNode( builder: ImageVector.Builder, node: VectorNode, tintBrush: Brush, - shouldTint: Boolean + shouldTintFill: Boolean, + strokeBrush: Brush?, + shouldTintStroke: Boolean, ) { when (node) { - is VectorGroup -> copyGroupInto(builder, node, tintBrush, shouldTint) - is VectorPath -> copyPathInto(builder, node, tintBrush, shouldTint) + is VectorGroup -> copyGroupInto( + builder = builder, + sourceGroup = node, + tintBrush = tintBrush, + shouldTintFill = shouldTintFill, + strokeBrush = strokeBrush, + shouldTintStroke = shouldTintStroke + ) + is VectorPath -> copyPathInto( + builder = builder, + sourcePath = node, + tintBrush = tintBrush, + shouldTintFill = shouldTintFill, + strokeBrush = strokeBrush, + shouldTintStroke = shouldTintStroke + ) } } @@ -63,7 +101,9 @@ private fun copyGroupInto( builder: ImageVector.Builder, sourceGroup: VectorGroup, tintBrush: Brush, - shouldTint: Boolean + shouldTintFill: Boolean, + strokeBrush: Brush?, + shouldTintStroke: Boolean, ) { builder.group( name = sourceGroup.name, @@ -78,7 +118,14 @@ private fun copyGroupInto( ) { val children = sourceGroup.toNodeList() children.forEach { child -> - copyNode(this, child, tintBrush, shouldTint) + copyNode( + builder = this, + node = child, + tintBrush = tintBrush, + shouldTintFill = shouldTintFill, + strokeBrush = strokeBrush, + shouldTintStroke = shouldTintStroke + ) } } } @@ -87,15 +134,17 @@ private fun copyPathInto( builder: ImageVector.Builder, sourcePath: VectorPath, tintBrush: Brush, - shouldTint: Boolean + shouldTintFill: Boolean, + strokeBrush: Brush?, + shouldTintStroke: Boolean, ) { builder.addPath( pathData = sourcePath.pathData, pathFillType = sourcePath.pathFillType, name = sourcePath.name, - fill = if (shouldTint) tintBrush else sourcePath.fill, + fill = if (shouldTintFill) tintBrush else sourcePath.fill, fillAlpha = sourcePath.fillAlpha, - stroke = if (shouldTint) tintBrush else sourcePath.stroke, + stroke = if (shouldTintStroke) strokeBrush ?: sourcePath.stroke else sourcePath.stroke, strokeAlpha = sourcePath.strokeAlpha, strokeLineWidth = sourcePath.strokeLineWidth, strokeLineCap = sourcePath.strokeLineCap, diff --git a/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt b/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt index 11b4cb4..a5c781a 100644 --- a/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt +++ b/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt @@ -8,7 +8,8 @@ import androidx.compose.ui.graphics.vector.group import androidx.compose.ui.unit.dp /** - * A multi-layer truck icon used as a UI-test fixture for [Icon] / [Image] with [TintCap]. + * A multi-layer truck icon used as a UI-test fixture for [Icon] / [Image] with [TintCap] + * and [TintStroke]. * * Top-level layers (indices) are drawn on non-overlapping regions so each one can be * pixel-tested in isolation: @@ -17,8 +18,8 @@ import androidx.compose.ui.unit.dp * 2 → `cab` (driver cabin shell + window, top-right) * 3 → `cargo` (cargo box, top-left) * - * Each layer uses a distinctive default colour so it is easy to verify which layers get - * tinted by each [TintCap] variant. + * Each layer uses a distinctive default fill colour and a distinctive default stroke colour + * so it is easy to verify which layers get tinted by each [TintCap] / [TintStroke] variant. */ val Icons.MapTruck: ImageVector get() = _MapTruck ?: ImageVector.Builder( @@ -73,7 +74,10 @@ val Icons.MapTruck: ImageVector ), name = "body", fill = SolidColor(Color(0xFFE53935)), - fillAlpha = 1f + fillAlpha = 1f, + stroke = SolidColor(Color(0xFFB71C1C)), + strokeAlpha = 1f, + strokeLineWidth = 1.5f ) // Layer 2: driver cabin (group: cabin shell + window, both tinted together) @@ -99,7 +103,10 @@ val Icons.MapTruck: ImageVector ), name = "cab-shell", fill = SolidColor(Color(0xFF1E88E5)), - fillAlpha = 1f + fillAlpha = 1f, + stroke = SolidColor(Color(0xFF0D47A1)), + strokeAlpha = 1f, + strokeLineWidth = 1.5f ) addPath( pathData = listOf( @@ -111,7 +118,10 @@ val Icons.MapTruck: ImageVector ), name = "cab-window", fill = SolidColor(Color(0xFFBBDEFB)), - fillAlpha = 1f + fillAlpha = 1f, + stroke = SolidColor(Color(0xFF0D47A1)), + strokeAlpha = 1f, + strokeLineWidth = 1.5f ) } @@ -126,7 +136,10 @@ val Icons.MapTruck: ImageVector ), name = "cargo", fill = SolidColor(Color(0xFF43A047)), - fillAlpha = 1f + fillAlpha = 1f, + stroke = SolidColor(Color(0xFF1B5E20)), + strokeAlpha = 1f, + strokeLineWidth = 1.5f ) }.build().also { _MapTruck = it } @@ -152,4 +165,4 @@ private fun tirePath(cx: Float, cy: Float, r: Float): List { * Holder that mirrors the `androidx.compose.material.icons.Icons` style so consumers can * write `Icons.MapTruck` exactly like a Material icon. */ -object Icons +object Icons \ No newline at end of file diff --git a/component/src/main/java/com/blipblipcode/component/image/TintStroke.kt b/component/src/main/java/com/blipblipcode/component/image/TintStroke.kt new file mode 100644 index 0000000..8552579 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/TintStroke.kt @@ -0,0 +1,78 @@ +package com.blipblipcode.component.image + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +/** + * Defines which layers of an [androidx.compose.ui.graphics.vector.ImageVector] receive the + * stroke tint color when rendering an [Icon] or [Image]. + * + * An ImageVector is composed of a tree of top-level nodes (groups and paths). Each top-level + * node is considered one "layer" and is identified by its position (zero-based) in the + * vector's root. + * + * - [All] Recolors the stroke of every layer. + * - [Index] Recolors the stroke of only the layer at the given position. + * - [Range] Recolors the stroke of every layer whose position lies inside the given [IntRange]. + * - [Layers] Recolors the stroke of only the layers at the specified positions. + * - [Undefined] Does not apply any stroke transformation; the vector's strokes are rendered with + * their original colors. + */ +@Stable +sealed class TintStroke { + + /** Whether this stroke tint cap should skip stroke recoloring entirely and preserve the vector's original stroke colors. */ + abstract val isUndefined: Boolean + + /** Returns `true` when the top-level node at [layerIndex] should receive the stroke tint color. */ + abstract fun appliesTo(layerIndex: Int): Boolean + + @Immutable + object All : TintStroke() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = true + override fun toString(): String = "TintStroke.All" + } + + @Immutable + object Undefined : TintStroke() { + override val isUndefined: Boolean = true + override fun appliesTo(layerIndex: Int): Boolean = false + override fun toString(): String = "TintStroke.Undefined" + } + + @Immutable + data class Index(val layer: Int) : TintStroke() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = layerIndex == layer + } + + @Immutable + data class Range(val range: IntRange) : TintStroke() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = layerIndex in range + } + + @Immutable + data class Layers(val layers: List) : TintStroke() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = layerIndex in layers + } + + companion object { + /** Builds a [TintStroke] that recolors the stroke of the single layer at [layer]. */ + fun index(layer: Int): TintStroke = Index(layer) + + /** Builds a [TintStroke] that recolors the stroke of every layer whose index lies inside [range]. */ + fun range(range: IntRange): TintStroke = Range(range) + + /** Builds a [TintStroke] that recolors the stroke of every layer whose index lies in `start..endInclusive`. */ + fun range(start: Int, endInclusive: Int): TintStroke = Range(start..endInclusive) + + /** Builds a [TintStroke] that recolors the stroke of every layer whose index appears in [layers]. */ + fun layers(vararg layers: Int): TintStroke = Layers(layers.toList()) + + /** Builds a [TintStroke] that recolors the stroke of every layer whose index appears in [layers]. */ + fun layers(layers: List): TintStroke = Layers(layers.toList()) + } +} \ No newline at end of file diff --git a/component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt b/component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt index d6cde6d..ecb51f4 100644 --- a/component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt +++ b/component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("KDocUnresolvedReference") + package com.blipblipcode.component.image import androidx.compose.ui.graphics.Color @@ -8,7 +10,6 @@ import androidx.compose.ui.graphics.vector.VectorPath import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotSame import org.junit.Assert.assertSame @@ -17,13 +18,14 @@ import org.junit.Test /** * Verifies the internal recolorImageVector function preserves the structure of the source - * ImageVector and applies the tint only to the requested top-level layers. + * ImageVector and applies the tint only to the requested top-level layers (both for fill + * via [tintCap] and for stroke via [tintStrokeCap]). */ class ImageVectorTinterTest { /** * Builds a 3-layer ImageVector where each top-level layer is a single path filled with - * a different distinctive color. + * a different distinctive color and stroked with a different distinctive color. */ private fun threeLayerVector(): ImageVector { val builder = ImageVector.Builder( @@ -36,17 +38,20 @@ class ImageVectorTinterTest { builder.addPath( pathData = emptyList(), name = "p0", - fill = SolidColor(Color.Red) + fill = SolidColor(Color.Red), + stroke = SolidColor(Color(0xFF800000)) ) builder.addPath( pathData = emptyList(), name = "p1", - fill = SolidColor(Color.Green) + fill = SolidColor(Color.Green), + stroke = SolidColor(Color(0xFF008000)) ) builder.addPath( pathData = emptyList(), name = "p2", - fill = SolidColor(Color.Blue) + fill = SolidColor(Color.Blue), + stroke = SolidColor(Color(0xFF000080)) ) return builder.build() } @@ -59,6 +64,8 @@ class ImageVectorTinterTest { return out } + // --- Fill tinting (legacy behaviour) ------------------------------------------- + @Test fun `Undefined returns the same source untouched`() { val source = threeLayerVector() @@ -67,7 +74,7 @@ class ImageVectorTinterTest { } @Test - fun `All rebuilds the vector with every layer tinted`() { + fun `All rebuilds the vector with every layer fill tinted`() { val source = threeLayerVector() val result = recolorImageVector(source, Color.Magenta, TintCap.All) val paths = topLevelPathsOf(result) @@ -79,7 +86,7 @@ class ImageVectorTinterTest { } @Test - fun `Index tints only the matching layer and preserves the others`() { + fun `Index tints only the matching layer fill and preserves the others`() { val source = threeLayerVector() val result = recolorImageVector(source, Color.Magenta, TintCap.index(1)) val paths = topLevelPathsOf(result) @@ -90,7 +97,7 @@ class ImageVectorTinterTest { } @Test - fun `Range tints every layer inside the range`() { + fun `Range tints every layer fill inside the range`() { val source = threeLayerVector() val result = recolorImageVector(source, Color.Magenta, TintCap.range(0..1)) val paths = topLevelPathsOf(result) @@ -100,7 +107,7 @@ class ImageVectorTinterTest { } @Test - fun `Layers tints only the specified positions`() { + fun `Layers tints only the specified positions fill`() { val source = threeLayerVector() val result = recolorImageVector(source, Color.Magenta, TintCap.layers(0, 2)) val paths = topLevelPathsOf(result) @@ -109,6 +116,126 @@ class ImageVectorTinterTest { assertEquals(Color.Magenta, (paths[2].fill as SolidColor).value) } + // --- Stroke tinting ----------------------------------------------------------- + + @Test + fun `stroke null leaves strokes untouched when fill is All`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.All) + val paths = topLevelPathsOf(result) + assertEquals(Color(0xFF800000), (paths[0].stroke as SolidColor).value) + assertEquals(Color(0xFF008000), (paths[1].stroke as SolidColor).value) + assertEquals(Color(0xFF000080), (paths[2].stroke as SolidColor).value) + } + + @Test + fun `stroke All recolors every layer stroke when fill is undefined`() { + val source = threeLayerVector() + val result = recolorImageVector( + source = source, + tint = Color.Black, + tintCap = TintCap.Undefined, + strokeTint = Color.Cyan, + strokeTintCap = TintStroke.All + ) + assertNotSame(source, result) + val paths = topLevelPathsOf(result) + assertEquals(Color.Cyan, (paths[0].stroke as SolidColor).value) + assertEquals(Color.Cyan, (paths[1].stroke as SolidColor).value) + assertEquals(Color.Cyan, (paths[2].stroke as SolidColor).value) + // Fill untouched + assertEquals(Color.Red, (paths[0].fill as SolidColor).value) + assertEquals(Color.Green, (paths[1].fill as SolidColor).value) + assertEquals(Color.Blue, (paths[2].fill as SolidColor).value) + } + + @Test + fun `stroke Index recolors only the matching layer stroke`() { + val source = threeLayerVector() + val result = recolorImageVector( + source = source, + tint = Color.Black, + tintCap = TintCap.Undefined, + strokeTint = Color.Cyan, + strokeTintCap = TintStroke.index(1) + ) + val paths = topLevelPathsOf(result) + assertEquals(Color(0xFF800000), (paths[0].stroke as SolidColor).value) + assertEquals(Color.Cyan, (paths[1].stroke as SolidColor).value) + assertEquals(Color(0xFF000080), (paths[2].stroke as SolidColor).value) + } + + @Test + fun `stroke Range recolors every layer stroke inside the range`() { + val source = threeLayerVector() + val result = recolorImageVector( + source = source, + tint = Color.Black, + tintCap = TintCap.Undefined, + strokeTint = Color.Cyan, + strokeTintCap = TintStroke.range(0..1) + ) + val paths = topLevelPathsOf(result) + assertEquals(Color.Cyan, (paths[0].stroke as SolidColor).value) + assertEquals(Color.Cyan, (paths[1].stroke as SolidColor).value) + assertEquals(Color(0xFF000080), (paths[2].stroke as SolidColor).value) + } + + @Test + fun `stroke Layers recolors only the specified positions`() { + val source = threeLayerVector() + val result = recolorImageVector( + source = source, + tint = Color.Black, + tintCap = TintCap.Undefined, + strokeTint = Color.Cyan, + strokeTintCap = TintStroke.layers(0, 2) + ) + val paths = topLevelPathsOf(result) + assertEquals(Color.Cyan, (paths[0].stroke as SolidColor).value) + assertEquals(Color(0xFF008000), (paths[1].stroke as SolidColor).value) + assertEquals(Color.Cyan, (paths[2].stroke as SolidColor).value) + } + + @Test + fun `Undefined stroke cap leaves strokes untouched even when strokeTint is provided`() { + val source = threeLayerVector() + val result = recolorImageVector( + source = source, + tint = Color.Black, + tintCap = TintCap.Undefined, + strokeTint = Color.Cyan, + strokeTintCap = TintStroke.Undefined + ) + val paths = topLevelPathsOf(result) + assertEquals(Color(0xFF800000), (paths[0].stroke as SolidColor).value) + assertEquals(Color(0xFF008000), (paths[1].stroke as SolidColor).value) + assertEquals(Color(0xFF000080), (paths[2].stroke as SolidColor).value) + } + + @Test + fun `fill and stroke can be recolored independently in the same call`() { + val source = threeLayerVector() + val result = recolorImageVector( + source = source, + tint = Color.Yellow, + tintCap = TintCap.index(0), + strokeTint = Color.Cyan, + strokeTintCap = TintStroke.index(1) + ) + val paths = topLevelPathsOf(result) + // Fill only on layer 0 + assertEquals(Color.Yellow, (paths[0].fill as SolidColor).value) + assertEquals(Color.Green, (paths[1].fill as SolidColor).value) + assertEquals(Color.Blue, (paths[2].fill as SolidColor).value) + // Stroke only on layer 1 + assertEquals(Color(0xFF800000), (paths[0].stroke as SolidColor).value) + assertEquals(Color.Cyan, (paths[1].stroke as SolidColor).value) + assertEquals(Color(0xFF000080), (paths[2].stroke as SolidColor).value) + } + + // --- Structural preservation -------------------------------------------------- + @Test fun `recoloring produces a fresh ImageVector instance`() { val source = threeLayerVector() diff --git a/component/src/test/java/com/blipblipcode/component/image/TintStrokeTest.kt b/component/src/test/java/com/blipblipcode/component/image/TintStrokeTest.kt new file mode 100644 index 0000000..2ac29e4 --- /dev/null +++ b/component/src/test/java/com/blipblipcode/component/image/TintStrokeTest.kt @@ -0,0 +1,91 @@ +package com.blipblipcode.component.image + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TintStrokeTest { + + @Test + fun `All recolors every stroke and is not undefined`() { + val cap = TintStroke.All + assertFalse(cap.isUndefined) + for (i in -1..10) { + assertTrue("layer $i should be stroked by All", cap.appliesTo(i)) + } + } + + @Test + fun `Undefined never recolors the stroke and reports itself as undefined`() { + val cap = TintStroke.Undefined + assertTrue(cap.isUndefined) + for (i in -5..20) { + assertFalse("layer $i should NOT be stroked by Undefined", cap.appliesTo(i)) + } + } + + @Test + fun `Index recolors only the matching layer`() { + val cap = TintStroke.index(3) + assertFalse(cap.isUndefined) + assertFalse(cap.appliesTo(2)) + assertTrue(cap.appliesTo(3)) + assertFalse(cap.appliesTo(4)) + } + + @Test + fun `Index works with negative and out-of-range positions`() { + val cap = TintStroke.index(0) + assertFalse(cap.appliesTo(-1)) + assertTrue(cap.appliesTo(0)) + assertFalse(cap.appliesTo(1)) + } + + @Test + fun `Range recolors every stroke inside the range, inclusive`() { + val cap = TintStroke.range(1..3) + assertFalse(cap.isUndefined) + assertFalse(cap.appliesTo(0)) + assertTrue(cap.appliesTo(1)) + assertTrue(cap.appliesTo(2)) + assertTrue(cap.appliesTo(3)) + assertFalse(cap.appliesTo(4)) + } + + @Test + fun `Range with start and endInclusive helper works`() { + val cap = TintStroke.range(0, 2) + assertTrue(cap.appliesTo(0)) + assertTrue(cap.appliesTo(1)) + assertTrue(cap.appliesTo(2)) + assertFalse(cap.appliesTo(3)) + } + + @Test + fun `Layers recolors only the specified positions`() { + val cap = TintStroke.layers(1, 3) + assertFalse(cap.isUndefined) + assertFalse(cap.appliesTo(0)) + assertTrue(cap.appliesTo(1)) + assertFalse(cap.appliesTo(2)) + assertTrue(cap.appliesTo(3)) + assertFalse(cap.appliesTo(4)) + } + + @Test + fun `Layers accepts a list factory`() { + val cap = TintStroke.layers(listOf(0, 4, 7)) + assertTrue(cap.appliesTo(0)) + assertFalse(cap.appliesTo(1)) + assertTrue(cap.appliesTo(4)) + assertTrue(cap.appliesTo(7)) + assertFalse(cap.appliesTo(8)) + } + + @Test + fun `Layer ordering is preserved`() { + val cap = TintStroke.layers(5, 0, 2) + assertEquals(listOf(5, 0, 2), (cap as TintStroke.Layers).layers) + } +} \ No newline at end of file