Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Basic CSS Color Parsing #24

Merged
merged 5 commits into from
Apr 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Comment on lines +5 to +8
Copy link
Member

@twyatt twyatt Apr 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect that regex approach is slower than processing by simple iteration.
But we can definitely postpone that optimization (and should benchmark to verify that regex is even slower).

Created #25 (super low priority though) to potentially address this later.


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