diff --git a/codecov.yml b/codecov.yml index 1d88cece..61c8e0cb 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,5 +2,6 @@ coverage: status: patch: off fixes: - - "com/juul/krayon/canvas::" - "com/juul/krayon/chart::" + - "com/juul/krayon/color::" + - "com/juul/krayon/kanvas::" diff --git a/color/api/color.api b/color/api/color.api index 81dfb1dd..b72826d0 100644 --- a/color/api/color.api +++ b/color/api/color.api @@ -18,6 +18,7 @@ public final class com/juul/krayon/color/Color { public static final fun getRgb-impl (I)I public fun hashCode ()I public static fun hashCode-impl (I)I + public static final fun toHexString-impl (I)Ljava/lang/String; public fun toString ()Ljava/lang/String; public static fun toString-impl (I)Ljava/lang/String; public final synthetic fun unbox-impl ()I @@ -32,6 +33,11 @@ public final class com/juul/krayon/color/RandomKt { public static synthetic fun nextColor$default (Lkotlin/random/Random;ZILjava/lang/Object;)I } +public final class com/juul/krayon/color/ToColorKt { + public static final fun toColor (Ljava/lang/String;)I + public static final fun toColorOrNull (Ljava/lang/String;)Lcom/juul/krayon/color/Color; +} + public final class com/juul/krayon/color/WebColorsKt { public static final fun getAliceBlue ()I public static final fun getAntiqueWhite ()I diff --git a/color/src/commonMain/kotlin/Color.kt b/color/src/commonMain/kotlin/Color.kt index 90d44af6..b78bfa1d 100644 --- a/color/src/commonMain/kotlin/Color.kt +++ b/color/src/commonMain/kotlin/Color.kt @@ -50,7 +50,18 @@ public inline class Color(public val argb: Int) { blue: Int = this.blue, ): Color = Color(alpha, red, green, blue) - override fun toString(): String = "Color(#${argb.toString(HEX_BASE).padStart(HEX_LENGTH, '0')})" + /** Returns this as a string in web-friendly hex notation. */ + public fun toHexString(): String = buildString { + append('#') + append(red.toString(HEX_BASE).padStart(2, '0')) + append(green.toString(HEX_BASE).padStart(2, '0')) + append(blue.toString(HEX_BASE).padStart(2, '0')) + if (alpha != 0xFF) { + append(alpha.toString(HEX_BASE).padStart(2, '0')) + } + } + + override fun toString(): String = "Color(${toHexString()})" } private fun Float.toColorComponent(): Int { diff --git a/color/src/commonMain/kotlin/Constants.kt b/color/src/commonMain/kotlin/Constants.kt index 7553cdfd..f501a6e9 100644 --- a/color/src/commonMain/kotlin/Constants.kt +++ b/color/src/commonMain/kotlin/Constants.kt @@ -9,7 +9,6 @@ internal const val SHIFT_GREEN = 8 internal const val SHIFT_BLUE = 0 internal const val HEX_BASE = 16 -internal const val HEX_LENGTH = 8 internal const val COMPONENT_MIN = 0x00 internal const val COMPONENT_MAX = 0xFF diff --git a/color/src/commonMain/kotlin/ToColor.kt b/color/src/commonMain/kotlin/ToColor.kt new file mode 100644 index 00000000..5a5c01ae --- /dev/null +++ b/color/src/commonMain/kotlin/ToColor.kt @@ -0,0 +1,47 @@ +package com.juul.krayon.color + +private const val HEX = "[a-f0-9]" + +private val RGB = """($HEX)($HEX)($HEX)""".toRegex(RegexOption.IGNORE_CASE) +private val RGBA = """($HEX)($HEX)($HEX)($HEX)""".toRegex(RegexOption.IGNORE_CASE) +private val RRGGBB = """($HEX{2})($HEX{2})($HEX{2})""".toRegex(RegexOption.IGNORE_CASE) +private val RRGGBBAA = """($HEX{2})($HEX{2})($HEX{2})($HEX{2})""".toRegex(RegexOption.IGNORE_CASE) + +public fun String.toColor(): Color = + when { + this.startsWith("#") -> parseHexNotation(this) + else -> keywordMap[this.toLowerCase()] + } ?: throw IllegalArgumentException("Unknown color: `$this`.") + +public fun String.toColorOrNull(): Color? = + try { + toColor() + } catch (e: IllegalArgumentException) { + null + } + +private fun parseHexNotation(text: String): Color { + require(text.startsWith("#")) { "Hex notation must start with a pound sign (#)." } + val hexText = text.substring(1) + val match = when (hexText.length) { + 3 -> RGB.matchEntire(hexText) + 4 -> RGBA.matchEntire(hexText) + 6 -> RRGGBB.matchEntire(hexText) + 8 -> RRGGBBAA.matchEntire(hexText) + else -> throw IllegalArgumentException("Incorrect length. Hex notation must contain exactly 3, 4, 6, or 8 hexadecimal characters.") + } ?: throw IllegalArgumentException("Text is not a hexadecimal string. Each character must match the regex [a-fA-F0-9].") + val red = match.groupValues[1] + val green = match.groupValues[2] + val blue = match.groupValues[3] + val alpha = match.groupValues.getOrNull(4) ?: "ff" + return Color(parseHexComponent(alpha), parseHexComponent(red), parseHexComponent(green), parseHexComponent(blue)) +} + +private fun parseHexComponent(component: String): Int = + if (component.length == 1) { + // Per MDN, "The three-digit notation (#RGB) is a shorter version of the six-digit form (#RRGGBB)." + // Achieve that by just calling this function again with the component repeated. + parseHexComponent(component + component) + } else { + component.toInt(HEX_BASE) + } diff --git a/color/src/commonMain/kotlin/WebColors.kt b/color/src/commonMain/kotlin/WebColors.kt index d2d364af..d11f7a03 100644 --- a/color/src/commonMain/kotlin/WebColors.kt +++ b/color/src/commonMain/kotlin/WebColors.kt @@ -186,3 +186,160 @@ public val gainsboro: Color = Color(0xFFDCDCDC.toInt()) public val transparent: Color = Color(0x00000000.toInt()) public val rebeccaPurple: Color = Color(0xFF663399.toInt()) + +internal val keywordMap = mapOf( + "white" to white, + "silver" to silver, + "gray" to gray, + "black" to black, + + "red" to red, + "maroon" to maroon, + "yellow" to yellow, + "olive" to olive, + "lime" to lime, + "green" to green, + "aqua" to aqua, + "teal" to teal, + "blue" to blue, + "navy" to navy, + "fuchsia" to fuchsia, + "purple" to purple, + + "mediumvioletred" to mediumVioletRed, + "deeppink" to deepPink, + "palevioletred" to paleVioletRed, + "hotpink" to hotPink, + "lightpink" to lightPink, + "pink" to pink, + + "darkred" to darkRed, + "firebrick" to firebrick, + "crimson" to crimson, + "indianred" to indianRed, + "lightcoral" to lightCoral, + "salmon" to salmon, + "lightsalmon" to lightSalmon, + + "orangered" to orangeRed, + "tomato" to tomato, + "darkorange" to darkOrange, + "coral" to coral, + "orange" to orange, + + "darkkhaki" to darkKhaki, + "gold" to gold, + "khaki" to khaki, + "peachpuff" to peachPuff, + "palegoldenrod" to paleGoldenrod, + "moccasin" to moccasin, + "papayawhip" to papayaWhip, + "lightgoldenrodyellow" to lightGoldenrodYellow, + "lemonchiffon" to lemonChiffon, + "lightyellow" to lightYellow, + + "brown" to brown, + "saddlebrown" to saddleBrown, + "sienna" to sienna, + "chocolate" to chocolate, + "darkgoldenrod" to darkGoldenrod, + "peru" to peru, + "rosybrown" to rosyBrown, + "goldenrod" to goldenrod, + "sandybrown" to sandyBrown, + "tan" to tan, + "burlywood" to burlywood, + "wheat" to wheat, + "navajowhite" to navajoWhite, + "bisque" to bisque, + "blanchedalmond" to blanchedAlmond, + "cornsilk" to cornsilk, + + "darkgreen" to darkGreen, + "darkolivegreen" to darkOliveGreen, + "forestgreen" to forestGreen, + "seagreen" to seaGreen, + "olivedrab" to oliveDrab, + "mediumseagreen" to mediumSeaGreen, + "limegreen" to limeGreen, + "springgreen" to springGreen, + "mediumspringgreen" to mediumSpringGreen, + "darkseagreen" to darkSeaGreen, + "mediumaquamarine" to mediumAquamarine, + "yellowgreen" to yellowGreen, + "lawngreen" to lawnGreen, + "chartreuse" to chartreuse, + "lightgreen" to lightGreen, + "greenyellow" to greenYellow, + "palegreen" to paleGreen, + + "darkcyan" to darkCyan, + "lightseagreen" to lightSeaGreen, + "cadetblue" to cadetBlue, + "darkturquoise" to darkTurquoise, + "mediumturquoise" to mediumTurquoise, + "turquoise" to turquoise, + "cyan" to cyan, + "aquamarine" to aquamarine, + "paleturquoise" to paleTurquoise, + "lightcyan" to lightCyan, + + "darkblue" to darkBlue, + "mediumblue" to mediumBlue, + "midnightblue" to midnightBlue, + "royalblue" to royalBlue, + "steelblue" to steelBlue, + "dodgerblue" to dodgerBlue, + "deepskyblue" to deepSkyBlue, + "cornflowerblue" to cornflowerBlue, + "skyblue" to skyBlue, + "lightskyblue" to lightSkyBlue, + "lightsteelblue" to lightSteelBlue, + "lightblue" to lightBlue, + "powderblue" to powderBlue, + + "indigo" to indigo, + "darkmagenta" to darkMagenta, + "darkviolet" to darkViolet, + "darkslateblue" to darkSlateBlue, + "blueviolet" to blueViolet, + "darkorchid" to darkOrchid, + "magenta" to magenta, + "slateblue" to slateBlue, + "mediumslateblue" to mediumSlateBlue, + "mediumorchid" to mediumOrchid, + "mediumpurple" to mediumPurple, + "orchid" to orchid, + "violet" to violet, + "plum" to plum, + "thistle" to thistle, + "lavender" to lavender, + + "mistyrose" to mistyRose, + "antiquewhite" to antiqueWhite, + "linen" to linen, + "beige" to beige, + "whitesmoke" to whiteSmoke, + "lavenderblush" to lavenderBlush, + "oldlace" to oldLace, + "aliceblue" to aliceBlue, + "seashell" to seashell, + "ghostwhite" to ghostWhite, + "honeydew" to honeydew, + "floralwhite" to floralWhite, + "azure" to azure, + "mintcream" to mintCream, + "snow" to snow, + "ivory" to ivory, + + "darkslategray" to darkSlateGray, + "dimgray" to dimGray, + "slategray" to slateGray, + "lightslategray" to lightSlateGray, + "darkgray" to darkGray, + "lightgray" to lightGray, + "gainsboro" to gainsboro, + + "transparent" to transparent, + "rebeccapurple" to rebeccaPurple +) diff --git a/color/src/commonTest/kotlin/ColorTests.kt b/color/src/commonTest/kotlin/ColorTests.kt index 8a495c76..8d2af54f 100644 --- a/color/src/commonTest/kotlin/ColorTests.kt +++ b/color/src/commonTest/kotlin/ColorTests.kt @@ -2,6 +2,7 @@ package com.juul.krayon.color import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class ColorTests { @@ -29,4 +30,29 @@ class ColorTests { fun checkComponentsAreMasked() { assertEquals((0x23456789).toInt(), Color(alpha = 0x123, red = 0x345, green = 0x567, blue = 0x789).argb) } + + @Test + fun colorConstructor_withFloatInput_matchesIntegerInput() { + assertEquals(red, Color(red = 1f, green = 0f, blue = 0f)) + } + + @Test + fun colorConstructor_withNegativeFloatInput_throwsException() { + assertFailsWith { Color(red = -1f, green = 0f, blue = 0f) } + } + + @Test + fun colorConstructor_withLargeFloatInput_throwsException() { + assertFailsWith { Color(red = 2f, green = 0f, blue = 0f) } + } + + @Test + fun toHexString_withNoTransparency_omitsAlpha() { + assertEquals("#ff0000", red.toHexString()) + } + + @Test + fun toHexString_withTransparency_includesAlpha() { + assertEquals("#ff000080", red.copy(alpha = 0x80).toHexString()) + } } diff --git a/color/src/commonTest/kotlin/OperationsTests.kt b/color/src/commonTest/kotlin/OperationsTests.kt new file mode 100644 index 00000000..96156194 --- /dev/null +++ b/color/src/commonTest/kotlin/OperationsTests.kt @@ -0,0 +1,12 @@ +package com.juul.krayon.color + +import kotlin.test.Test +import kotlin.test.assertEquals + +class OperationsTests { + + @Test + fun lerp_fromWhiteToBlack_isGray() { + assertEquals(gray, lerp(white, black, 0.5f)) + } +} diff --git a/color/src/commonTest/kotlin/RandomTests.kt b/color/src/commonTest/kotlin/RandomTests.kt new file mode 100644 index 00000000..627f1d11 --- /dev/null +++ b/color/src/commonTest/kotlin/RandomTests.kt @@ -0,0 +1,31 @@ +package com.juul.krayon.color + +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class RandomTests { + + private val saturatedRandom = object : Random() { + override fun nextBits(bitCount: Int): Int = 0xFFFFFFFF.toInt() ushr (32 - bitCount) + } + + private val zeroRandom = object : Random() { + override fun nextBits(bitCount: Int): Int = 0 + } + + @Test + fun nextColor_withSaturatedRandom_isWhite() { + assertEquals(white.rgb, saturatedRandom.nextColor().rgb) + } + + @Test + fun nextColor_withDefaultArg_isOpaque() { + assertEquals(0xFF, zeroRandom.nextColor().alpha) + } + + @Test + fun nextColor_withOpaqueFalse_canProduceTransparency() { + assertEquals(0, zeroRandom.nextColor(isOpaque = false).alpha) + } +} diff --git a/color/src/commonTest/kotlin/ToColorTests.kt b/color/src/commonTest/kotlin/ToColorTests.kt new file mode 100644 index 00000000..9ca883aa --- /dev/null +++ b/color/src/commonTest/kotlin/ToColorTests.kt @@ -0,0 +1,68 @@ +package com.juul.krayon.color + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class ToColorTests { + + @Test + fun toColor_withKeyword_succeeds() { + assertEquals(cornflowerBlue, "cornflowerBlue".toColor()) + } + + @Test + fun toColor_withRGBInput_succeeds() { + assertEquals(red, "#f00".toColor()) + assertEquals(lime, "#0f0".toColor()) + assertEquals(blue, "#00f".toColor()) + } + + @Test + fun toColor_withRGBAInput_succeeds() { + assertEquals(red.copy(alpha = 0x00), "#f000".toColor()) + assertEquals(lime.copy(alpha = 0x88), "#0f08".toColor()) + assertEquals(blue.copy(alpha = 0xFF), "#00ff".toColor()) + } + + @Test + fun toColor_withRRGGBBInput_succeeds() { + assertEquals(cornflowerBlue, "#6495ed".toColor()) + } + + @Test + fun toColor_withRRGGBBAAInput_succeeds() { + assertEquals(cornflowerBlue.copy(alpha = 0x48), "#6495ed48".toColor()) + } + + @Test + fun toColor_onMissingCrunch_throwsException() { + assertFailsWith { "fff".toColor() } + } + + @Test + fun toColorOrNull_onMissingCrunch_returnsNull() { + assertNull("fff".toColorOrNull()) + } + + @Test + fun toColor_onIncorrectLength_throwsException() { + assertFailsWith { "#fffff".toColor() } + } + + @Test + fun toColorOrNull_onIncorrectLength_returnsNull() { + assertNull("#fffff".toColorOrNull()) + } + + @Test + fun toColor_onInvalidCharacter_throwsException() { + assertFailsWith { "#fgf".toColor() } + } + + @Test + fun toColorOrNull_onInvalidCharacter_returnsNull() { + assertNull("#fgf".toColorOrNull()) + } +}