Skip to content

Commit

Permalink
Add Basic CSS Color Parsing (#24)
Browse files Browse the repository at this point in the history
* Add CSS color parsing (and also write a few bonus tests)

* Fix codecov path fixes

* Use string prefix to determine parsing technique

* Remove accidental double blank line

* Add tests for the Random extensions
  • Loading branch information
cedrickcooke authored Apr 2, 2021
1 parent 2cd38aa commit 52c530a
Show file tree
Hide file tree
Showing 10 changed files with 361 additions and 3 deletions.
3 changes: 2 additions & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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::"
6 changes: 6 additions & 0 deletions color/api/color.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion color/src/commonMain/kotlin/Color.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion color/src/commonMain/kotlin/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions color/src/commonMain/kotlin/ToColor.kt
Original file line number Diff line number Diff line change
@@ -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)
}
157 changes: 157 additions & 0 deletions color/src/commonMain/kotlin/WebColors.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
26 changes: 26 additions & 0 deletions color/src/commonTest/kotlin/ColorTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.juul.krayon.color

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class ColorTests {

Expand Down Expand Up @@ -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<IllegalArgumentException> { Color(red = -1f, green = 0f, blue = 0f) }
}

@Test
fun colorConstructor_withLargeFloatInput_throwsException() {
assertFailsWith<IllegalArgumentException> { 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())
}
}
12 changes: 12 additions & 0 deletions color/src/commonTest/kotlin/OperationsTests.kt
Original file line number Diff line number Diff line change
@@ -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))
}
}
31 changes: 31 additions & 0 deletions color/src/commonTest/kotlin/RandomTests.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 52c530a

Please sign in to comment.