diff --git a/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt b/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt new file mode 100644 index 00000000..66686260 --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/CSSColorTests.kt @@ -0,0 +1,38 @@ +package com.futo.platformplayer + +import android.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Test +import toAndroidColor + +class CSSColorTests { + @Test + fun test1() { + val androidHex = "#80336699" + val androidColorInt = Color.parseColor(androidHex) + + val cssHex = "#33669980" + val cssColor = CSSColor.parseColor(cssHex) + + assertEquals( + "CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)", + androidColorInt, + cssColor.toAndroidColor(), + ) + } + + @Test + fun test2() { + val androidHex = "#123ABC" + val androidColorInt = Color.parseColor(androidHex) + + val cssHex = "#123ABCFF" + val cssColor = CSSColor.parseColor(cssHex) + + assertEquals( + "CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)", + androidColorInt, + cssColor.toAndroidColor() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/CSSColor.kt b/app/src/main/java/com/futo/platformplayer/CSSColor.kt new file mode 100644 index 00000000..73b50413 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/CSSColor.kt @@ -0,0 +1,319 @@ +import kotlin.math.* + +class CSSColor(r: Float, g: Float, b: Float, a: Float = 1f) { + init { + require(r in 0f..1f && g in 0f..1f && b in 0f..1f && a in 0f..1f) { + "RGBA channels must be in [0,1]" + } + } + + // -- RGB(A) channels stored 0–1 -- + var r: Float = r.coerceIn(0f, 1f) + set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true } + var g: Float = g.coerceIn(0f, 1f) + set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true } + var b: Float = b.coerceIn(0f, 1f) + set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true } + var a: Float = a.coerceIn(0f, 1f) + set(v) { field = v.coerceIn(0f, 1f) } + + // -- Int views of RGBA 0–255 -- + var red: Int + get() = (r * 255).roundToInt() + set(v) { r = (v.coerceIn(0, 255) / 255f) } + var green: Int + get() = (g * 255).roundToInt() + set(v) { g = (v.coerceIn(0, 255) / 255f) } + var blue: Int + get() = (b * 255).roundToInt() + set(v) { b = (v.coerceIn(0, 255) / 255f) } + var alpha: Int + get() = (a * 255).roundToInt() + set(v) { a = (v.coerceIn(0, 255) / 255f) } + + // -- HSLA storage & lazy recompute flags -- + private var _h: Float = 0f + private var _s: Float = 0f + private var _l: Float = 0f + private var _hslDirty = true + + /** Hue [0...360) */ + var hue: Float + get() { computeHslIfNeeded(); return _h } + set(v) { setHsl(v, saturation, lightness) } + + /** Saturation [0...1] */ + var saturation: Float + get() { computeHslIfNeeded(); return _s } + set(v) { setHsl(hue, v, lightness) } + + /** Lightness [0...1] */ + var lightness: Float + get() { computeHslIfNeeded(); return _l } + set(v) { setHsl(hue, saturation, v) } + + private fun computeHslIfNeeded() { + if (!_hslDirty) return + val max = max(max(r, g), b) + val min = min(min(r, g), b) + val d = max - min + _l = (max + min) / 2f + _s = if (d == 0f) 0f else d / (1f - abs(2f * _l - 1f)) + _h = when { + d == 0f -> 0f + max == r -> ((g - b) / d % 6f) * 60f + max == g -> (((b - r) / d) + 2f) * 60f + else -> (((r - g) / d) + 4f) * 60f + }.let { if (it < 0f) it + 360f else it } + _hslDirty = false + } + + /** + * Set all three HSL channels at once. + * Hue in degrees [0...360), s/l [0...1]. + */ + fun setHsl(h: Float, s: Float, l: Float) { + val hh = ((h % 360f) + 360f) % 360f + val cc = (1f - abs(2f * l - 1f)) * s + val x = cc * (1f - abs((hh / 60f) % 2f - 1f)) + val m = l - cc / 2f + + val (rp, gp, bp) = when { + hh < 60f -> Triple(cc, x, 0f) + hh < 120f -> Triple(x, cc, 0f) + hh < 180f -> Triple(0f, cc, x) + hh < 240f -> Triple(0f, x, cc) + hh < 300f -> Triple(x, 0f, cc) + else -> Triple(cc, 0f, x) + } + + r = rp + m; g = gp + m; b = bp + m + _h = hh; _s = s; _l = l; _hslDirty = false + } + + /** Return 0xRRGGBBAA int */ + fun toRgbaInt(): Int { + val ai = (a * 255).roundToInt() and 0xFF + val ri = (r * 255).roundToInt() and 0xFF + val gi = (g * 255).roundToInt() and 0xFF + val bi = (b * 255).roundToInt() and 0xFF + return (ri shl 24) or (gi shl 16) or (bi shl 8) or ai + } + + /** Return 0xAARRGGBB int */ + fun toArgbInt(): Int { + val ai = (a * 255).roundToInt() and 0xFF + val ri = (r * 255).roundToInt() and 0xFF + val gi = (g * 255).roundToInt() and 0xFF + val bi = (b * 255).roundToInt() and 0xFF + return (ai shl 24) or (ri shl 16) or (gi shl 8) or bi + } + + // — Convenience modifiers (chainable) — + + /** Lighten by fraction [0...1] */ + fun lighten(fraction: Float): CSSColor = apply { + lightness = (lightness + fraction).coerceIn(0f, 1f) + } + + /** Darken by fraction [0...1] */ + fun darken(fraction: Float): CSSColor = apply { + lightness = (lightness - fraction).coerceIn(0f, 1f) + } + + /** Increase saturation by fraction [0...1] */ + fun saturate(fraction: Float): CSSColor = apply { + saturation = (saturation + fraction).coerceIn(0f, 1f) + } + + /** Decrease saturation by fraction [0...1] */ + fun desaturate(fraction: Float): CSSColor = apply { + saturation = (saturation - fraction).coerceIn(0f, 1f) + } + + /** Rotate hue by degrees (can be negative) */ + fun rotateHue(degrees: Float): CSSColor = apply { + hue = (hue + degrees) % 360f + } + + companion object { + /** Create from Android 0xAARRGGBB */ + @JvmStatic fun fromArgb(color: Int): CSSColor { + val a = ((color ushr 24) and 0xFF) / 255f + val r = ((color ushr 16) and 0xFF) / 255f + val g = ((color ushr 8) and 0xFF) / 255f + val b = ( color and 0xFF) / 255f + return CSSColor(r, g, b, a) + } + + /** Create from Android 0xRRGGBBAA */ + @JvmStatic fun fromRgba(color: Int): CSSColor { + val r = ((color ushr 24) and 0xFF) / 255f + val g = ((color ushr 16) and 0xFF) / 255f + val b = ((color ushr 8) and 0xFF) / 255f + val a = ( color and 0xFF) / 255f + return CSSColor(r, g, b, a) + } + + @JvmStatic fun fromAndroidColor(color: Int): CSSColor { + return fromArgb(color) + } + + private val NAMED_HEX = mapOf( + "aliceblue" to "F0F8FF", "antiquewhite" to "FAEBD7", "aqua" to "00FFFF", + "aquamarine" to "7FFFD4", "azure" to "F0FFFF", "beige" to "F5F5DC", + "bisque" to "FFE4C4", "black" to "000000", "blanchedalmond" to "FFEBCD", + "blue" to "0000FF", "blueviolet" to "8A2BE2", "brown" to "A52A2A", + "burlywood" to "DEB887", "cadetblue" to "5F9EA0", "chartreuse" to "7FFF00", + "chocolate" to "D2691E", "coral" to "FF7F50", "cornflowerblue" to "6495ED", + "cornsilk" to "FFF8DC", "crimson" to "DC143C", "cyan" to "00FFFF", + "darkblue" to "00008B", "darkcyan" to "008B8B", "darkgoldenrod" to "B8860B", + "darkgray" to "A9A9A9", "darkgreen" to "006400", "darkgrey" to "A9A9A9", + "darkkhaki" to "BDB76B", "darkmagenta" to "8B008B", "darkolivegreen" to "556B2F", + "darkorange" to "FF8C00", "darkorchid" to "9932CC", "darkred" to "8B0000", + "darksalmon" to "E9967A", "darkseagreen" to "8FBC8F", "darkslateblue" to "483D8B", + "darkslategray" to "2F4F4F", "darkslategrey" to "2F4F4F", "darkturquoise" to "00CED1", + "darkviolet" to "9400D3", "deeppink" to "FF1493", "deepskyblue" to "00BFFF", + "dimgray" to "696969", "dimgrey" to "696969", "dodgerblue" to "1E90FF", + "firebrick" to "B22222", "floralwhite" to "FFFAF0", "forestgreen" to "228B22", + "fuchsia" to "FF00FF", "gainsboro" to "DCDCDC", "ghostwhite" to "F8F8FF", + "gold" to "FFD700", "goldenrod" to "DAA520", "gray" to "808080", + "green" to "008000", "greenyellow" to "ADFF2F", "grey" to "808080", + "honeydew" to "F0FFF0", "hotpink" to "FF69B4", "indianred" to "CD5C5C", + "indigo" to "4B0082", "ivory" to "FFFFF0", "khaki" to "F0E68C", + "lavender" to "E6E6FA", "lavenderblush" to "FFF0F5", "lawngreen" to "7CFC00", + "lemonchiffon" to "FFFACD", "lightblue" to "ADD8E6", "lightcoral" to "F08080", + "lightcyan" to "E0FFFF", "lightgoldenrodyellow" to "FAFAD2", "lightgray" to "D3D3D3", + "lightgreen" to "90EE90", "lightgrey" to "D3D3D3", "lightpink" to "FFB6C1", + "lightsalmon" to "FFA07A", "lightseagreen" to "20B2AA", "lightskyblue" to "87CEFA", + "lightslategray" to "778899", "lightslategrey" to "778899", "lightsteelblue" to "B0C4DE", + "lightyellow" to "FFFFE0", "lime" to "00FF00", "limegreen" to "32CD32", + "linen" to "FAF0E6", "magenta" to "FF00FF", "maroon" to "800000", + "mediumaquamarine" to "66CDAA", "mediumblue" to "0000CD", "mediumorchid" to "BA55D3", + "mediumpurple" to "9370DB", "mediumseagreen" to "3CB371", "mediumslateblue" to "7B68EE", + "mediumspringgreen" to "00FA9A", "mediumturquoise" to "48D1CC", "mediumvioletred" to "C71585", + "midnightblue" to "191970", "mintcream" to "F5FFFA", "mistyrose" to "FFE4E1", + "moccasin" to "FFE4B5", "navajowhite" to "FFDEAD", "navy" to "000080", + "oldlace" to "FDF5E6", "olive" to "808000", "olivedrab" to "6B8E23", + "orange" to "FFA500", "orangered" to "FF4500", "orchid" to "DA70D6", + "palegoldenrod" to "EEE8AA", "palegreen" to "98FB98", "paleturquoise" to "AFEEEE", + "palevioletred" to "DB7093", "papayawhip" to "FFEFD5", "peachpuff" to "FFDAB9", + "peru" to "CD853F", "pink" to "FFC0CB", "plum" to "DDA0DD", + "powderblue" to "B0E0E6", "purple" to "800080", "rebeccapurple" to "663399", + "red" to "FF0000", "rosybrown" to "BC8F8F", "royalblue" to "4169E1", + "saddlebrown" to "8B4513", "salmon" to "FA8072", "sandybrown" to "F4A460", + "seagreen" to "2E8B57", "seashell" to "FFF5EE", "sienna" to "A0522D", + "silver" to "C0C0C0", "skyblue" to "87CEEB", "slateblue" to "6A5ACD", + "slategray" to "708090", "slategrey" to "708090", "snow" to "FFFAFA", + "springgreen" to "00FF7F", "steelblue" to "4682B4", "tan" to "D2B48C", + "teal" to "008080", "thistle" to "D8BFD8", "tomato" to "FF6347", + "turquoise" to "40E0D0", "violet" to "EE82EE", "wheat" to "F5DEB3", + "white" to "FFFFFF", "whitesmoke" to "F5F5F5", "yellow" to "FFFF00", + "yellowgreen" to "9ACD32" + ) + private val NAMED: Map = NAMED_HEX + .mapValues { (_, hexRgb) -> + // parse hexRgb ("RRGGBB") to Int, then OR in 0xFF000000 for full opacity + val rgb = hexRgb.toInt(16) + (rgb shl 8) or 0xFF + } + ("transparent" to 0x00000000) + + private val HEX_REGEX = Regex("^#([0-9a-fA-F]{3,8})$", RegexOption.IGNORE_CASE) + private val RGB_REGEX = Regex("^rgba?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE) + private val HSL_REGEX = Regex("^hsla?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE) + + @JvmStatic + fun parseColor(s: String): CSSColor { + val str = s.trim() + // named + NAMED[str.lowercase()]?.let { return it.RGBAtoCSSColor() } + + // hex + HEX_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { part -> + return parseHexPart(part) + } + + // rgb/rgba + RGB_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { + return parseRgbParts(it.split(',').map(String::trim)) + } + + // hsl/hsla + HSL_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { + return parseHslParts(it.split(',').map(String::trim)) + } + + error("Cannot parse color: \"$s\"") + } + + private fun parseHexPart(p: String): CSSColor { + // expand shorthand like "RGB" or "RGBA" to full 8-chars "RRGGBBAA" + val hex = when (p.length) { + 3 -> p.map { "$it$it" }.joinToString("") + "FF" + 4 -> p.map { "$it$it" }.joinToString("") + 6 -> p + "FF" + 8 -> p + else -> error("Invalid hex color: #$p") + } + + val parsed = hex.toLong(16).toInt() + val alpha = (parsed and 0xFF) shl 24 + val rgbOnly = (parsed ushr 8) and 0x00FFFFFF + val argb = alpha or rgbOnly + return fromArgb(argb) + } + + private fun parseRgbParts(parts: List): CSSColor { + require(parts.size == 3 || parts.size == 4) { "rgb/rgba needs 3 or 4 parts" } + + // r/g/b: "128" → 128/255, "50%" → 0.5 + fun channel(ch: String): Float = + if (ch.endsWith("%")) ch.removeSuffix("%").toFloat() / 100f + else ch.toFloat().coerceIn(0f, 255f) / 255f + + // alpha: "0.5" → 0.5, "50%" → 0.5 + fun alpha(a: String): Float = + if (a.endsWith("%")) a.removeSuffix("%").toFloat() / 100f + else a.toFloat().coerceIn(0f, 1f) + + val r = channel(parts[0]) + val g = channel(parts[1]) + val b = channel(parts[2]) + val a = if (parts.size == 4) alpha(parts[3]) else 1f + + return CSSColor(r, g, b, a) + } + + private fun parseHslParts(parts: List): CSSColor { + require(parts.size == 3 || parts.size == 4) { "hsl/hsla needs 3 or 4 parts" } + + fun hueOf(h: String): Float = when { + h.endsWith("deg") -> h.removeSuffix("deg").toFloat() + h.endsWith("grad") -> h.removeSuffix("grad").toFloat() * 0.9f + h.endsWith("rad") -> h.removeSuffix("rad").toFloat() * (180f / PI.toFloat()) + h.endsWith("turn") -> h.removeSuffix("turn").toFloat() * 360f + else -> h.toFloat() + } + + // for s and l you only ever see percentages + fun pct(p: String): Float = + p.removeSuffix("%").toFloat().coerceIn(0f, 100f) / 100f + + // alpha: "0.5" → 0.5, "50%" → 0.5 + fun alpha(a: String): Float = + if (a.endsWith("%")) pct(a) + else a.toFloat().coerceIn(0f, 1f) + + val h = hueOf(parts[0]) + val s = pct(parts[1]) + val l = pct(parts[2]) + val a = if (parts.size == 4) alpha(parts[3]) else 1f + + return CSSColor(0f, 0f, 0f, a).apply { setHsl(h, s, l) } + } + } +} + +fun Int.RGBAtoCSSColor(): CSSColor = CSSColor.fromRgba(this) +fun Int.ARGBtoCSSColor(): CSSColor = CSSColor.fromArgb(this) +fun CSSColor.toAndroidColor(): Int = toArgbInt() diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt index 7028d59d..ebd75b44 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventEmojis.kt @@ -18,8 +18,7 @@ class LiveEventEmojis: IPlatformLiveEvent { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis { obj.ensureIsBusy(); val contextName = "LiveEventEmojis" - return LiveEventEmojis( - obj.getOrThrow(config, "emojis", contextName)); + return LiveEventEmojis(obj.getOrThrow(config, "emojis", contextName)); } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt index 05577fcb..1a4087a8 100644 --- a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationListItem.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.views.livechat +import CSSColor import android.graphics.Color import android.graphics.drawable.LevelListDrawable import android.text.Spannable @@ -24,6 +25,7 @@ import com.futo.platformplayer.views.adapters.AnyAdapter import com.futo.platformplayer.views.overlays.LiveChatOverlay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import toAndroidColor class LiveChatDonationListItem(viewGroup: ViewGroup) : LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_donation, viewGroup, false)) { @@ -55,10 +57,10 @@ class LiveChatDonationListItem(viewGroup: ViewGroup) _amount.text = event.amount.trim(); if(event.colorDonation != null && event.colorDonation.isHexColor()) { - val color = Color.parseColor(event.colorDonation); - _amountContainer.background.setTint(color); + val color = CSSColor.parseColor(event.colorDonation); + _amountContainer.background.setTint(color.toAndroidColor()); - if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) + if(color.lightness > 0.5) _amount.setTextColor(Color.BLACK); else _amount.setTextColor(Color.WHITE); diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt index 02619424..34ae1c1b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatDonationPill.kt @@ -13,6 +13,7 @@ import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.api.media.models.live.LiveEventDonation import com.futo.platformplayer.isHexColor +import toAndroidColor class LiveChatDonationPill: LinearLayout { private val _imageAuthor: ImageView; @@ -33,10 +34,10 @@ class LiveChatDonationPill: LinearLayout { if(donation.colorDonation != null && donation.colorDonation.isHexColor()) { - val color = Color.parseColor(donation.colorDonation); - root.background.setTint(color); + val color = CSSColor.parseColor(donation.colorDonation); + root.background.setTint(color.toAndroidColor()); - if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) + if(color.lightness > 0.5) _textAmount.setTextColor(Color.BLACK); else _textAmount.setTextColor(Color.WHITE); diff --git a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt index ffe7f1b3..df742225 100644 --- a/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt +++ b/app/src/main/java/com/futo/platformplayer/views/livechat/LiveChatMessageListItem.kt @@ -18,6 +18,7 @@ import com.futo.platformplayer.views.adapters.AnyAdapter import com.futo.platformplayer.views.overlays.LiveChatOverlay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import toAndroidColor class LiveChatMessageListItem(viewGroup: ViewGroup) : LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_message, viewGroup, false)) { @@ -75,7 +76,7 @@ class LiveChatMessageListItem(viewGroup: ViewGroup) if (!event.colorName.isNullOrEmpty()) { try { - _authorName.setTextColor(Color.parseColor(event.colorName)); + _authorName.setTextColor(CSSColor.parseColor(event.colorName).toAndroidColor()); } catch (ex: Throwable) { } } else diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt index fc3eff23..ea20bf74 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/LiveChatOverlay.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import toAndroidColor class LiveChatOverlay : LinearLayout { @@ -291,10 +292,10 @@ class LiveChatOverlay : LinearLayout { _overlayDonation_Amount.text = donation.amount.trim(); _overlayDonation.visibility = VISIBLE; if(donation.colorDonation != null && donation.colorDonation.isHexColor()) { - val color = Color.parseColor(donation.colorDonation); - _overlayDonation_AmountContainer.background.setTint(color); + val color = CSSColor.parseColor(donation.colorDonation); + _overlayDonation_AmountContainer.background.setTint(color.toAndroidColor()); - if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) + if(color.lightness > 0.5) _overlayDonation_Amount.setTextColor(Color.BLACK) else _overlayDonation_Amount.setTextColor(Color.WHITE); diff --git a/app/src/test/java/com/futo/platformplayer/CSSColorTests.kt b/app/src/test/java/com/futo/platformplayer/CSSColorTests.kt new file mode 100644 index 00000000..e8f780cf --- /dev/null +++ b/app/src/test/java/com/futo/platformplayer/CSSColorTests.kt @@ -0,0 +1,257 @@ +package com.futo.platformplayer + +import CSSColor +import org.junit.Assert.assertEquals +import kotlin.math.PI +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertTrue + +class CSSColorTest { + + private fun approxEq(expected: Float, actual: Float, eps: Float = 1e-5f) { + assertTrue(abs(expected - actual) <= eps, "Expected $expected but got $actual") + } + + @Test fun `hex #RRGGBB parses correctly`() { + val c = CSSColor.parseColor("#336699") + assertEquals(0x33, c.red) + assertEquals(0x66, c.green) + assertEquals(0x99, c.blue) + assertEquals(255, c.alpha) + } + + @Test fun `hex #RGB shorthand expands`() { + val c = CSSColor.parseColor("#369") + assertEquals(0x33, c.red) + assertEquals(0x66, c.green) + assertEquals(0x99, c.blue) + } + + @Test fun `hex #RRGGBBAA parses alpha`() { + val c = CSSColor.parseColor("#33669980") + assertEquals(0x33, c.red) + assertEquals(0x66, c.green) + assertEquals(0x99, c.blue) + approxEq(128 / 255f, c.a) + assertEquals(128, c.alpha) + } + + @Test fun `hex #RGBA shorthand expands with alpha`() { + val c = CSSColor.parseColor("#3698") + assertEquals(0x33, c.red) + assertEquals(0x66, c.green) + assertEquals(0x99, c.blue) + assertEquals(0x88, c.alpha) + } + + @Test fun `hex uppercase shorthand parses`() { + val c = CSSColor.parseColor("#AbC") + // expands to AABBCC + assertEquals(0xAA, c.red) + assertEquals(0xBB, c.green) + assertEquals(0xCC, c.blue) + } + + @Test fun `rgb(ints) functional parser`() { + val c = CSSColor.parseColor("rgb(255,128,0)") + assertEquals(255, c.red) + assertEquals(128, c.green) + assertEquals(0, c.blue) + assertEquals(255, c.alpha) + } + + @Test fun `rgb(percent) functional parser`() { + val c = CSSColor.parseColor("rgb(100%,50%,0%)") + assertEquals(255, c.red) + assertEquals(128, c.green) + assertEquals(0, c.blue) + } + + @Test fun `rgba raw‐float alpha functional parser`() { + val c = CSSColor.parseColor("rgba(255,0,0,0.5)") + assertEquals(255, c.red) + assertEquals(0, c.green) + assertEquals(0, c.blue) + approxEq(0.5f, c.a) + } + + @Test fun `rgba percent alpha functional parser`() { + val c = CSSColor.parseColor("rgba(100%,0%,0%,50%)") + assertEquals(255, c.red) + assertEquals(0, c.green) + assertEquals(0, c.blue) + approxEq(0.5f, c.a) + } + + @Test fun `hsl() functional parser yields correct RGB`() { + // pure green: hue=120°, sat=100%, light=50% + val c = CSSColor.parseColor("hsl(120,100%,50%)") + assertEquals(0, c.red) + assertEquals(255, c.green) + assertEquals(0, c.blue) + } + + @Test fun `hsla percent alpha functional parser`() { + val c = CSSColor.parseColor("hsla(240,100%,50%,25%)") + // pure blue, alpha 25% + assertEquals(0, c.red) + assertEquals(0, c.green) + assertEquals(255, c.blue) + approxEq(0.25f, c.a) + } + + @Test fun `hsla raw‐float alpha functional parser`() { + val c = CSSColor.parseColor("hsla(240,100%,50%,0.25)") + assertEquals(0, c.red) + assertEquals(0, c.green) + assertEquals(255, c.blue) + approxEq(0.25f, c.a) + } + + @Test fun `hsl radian unit parsing`() { + // 180° = π radians → cyan + val c = CSSColor.parseColor("hsl(${PI}rad,100%,50%)") + assertEquals(0, c.red) + assertEquals(255, c.green) + assertEquals(255, c.blue) + } + + @Test fun `hsl turn unit parsing`() { + // 0.5 turn = 180° → cyan + val c = CSSColor.parseColor("hsl(0.5turn,100%,50%)") + assertEquals(0, c.red) + assertEquals(255, c.green) + assertEquals(255, c.blue) + } + + @Test fun `hsl grad unit parsing`() { + // 200 grad = 180° → cyan + val c = CSSColor.parseColor("hsl(200grad,100%,50%)") + assertEquals(0, c.red) + assertEquals(255, c.green) + assertEquals(255, c.blue) + } + + @Test fun `named colors parse`() { + val red = CSSColor.parseColor("red") + assertEquals(255, red.red) + assertEquals(0, red.green) + assertEquals(0, red.blue) + + val rebecca = CSSColor.parseColor("rebeccapurple") + assertEquals(0x66, rebecca.red) + assertEquals(0x33, rebecca.green) + assertEquals(0x99, rebecca.blue) + + val transparent = CSSColor.parseColor("transparent") + assertEquals(0, transparent.alpha) + } + + @Test fun `round-trip Android Int ↔ CSSColor`() { + val original = CSSColor(0.2f, 0.4f, 0.6f, 0.8f) + val colorInt = original.toRgbaInt() + val back = CSSColor.fromRgba(colorInt) + approxEq(original.r, back.r) + approxEq(original.g, back.g) + approxEq(original.b, back.b) + approxEq(original.a, back.a) + } + + @Test fun `individual channel setters`() { + val c = CSSColor(0f,0f,0f,1f) + c.red = 128; assertEquals(128, c.red); approxEq(128/255f, c.r) + c.green = 64; assertEquals(64, c.green); approxEq(64/255f, c.g) + c.blue = 32; assertEquals(32, c.blue); approxEq(32/255f, c.b) + c.alpha = 200; assertEquals(200, c.alpha); approxEq(200/255f, c.a) + } + + @Test fun `hsl channel setters update RGB`() { + val c = CSSColor.parseColor("hsl(0,100%,50%)") // red + c.hue = 120f // → green + assertEquals(0, c.red) + assertEquals(255, c.green) + assertEquals(0, c.blue) + + c.saturation = 0f // → gray + assertTrue(c.red == c.green && c.green == c.blue) + } + + @Test fun `convenience modifiers chain as expected`() { + val c = CSSColor.parseColor("#888888") + .lighten(0.1f) + .saturate(0.2f) + .rotateHue(45f) + + approxEq(0.633f, c.lightness, eps = 1e-3f) + approxEq(0.2f, c.saturation, eps = 1e-3f) + approxEq(45f, c.hue) + } + + @Test + fun `invalid formats throw IllegalArgumentException`() { + listOf("", "rgb()", "hsl(0,0)", "#12", "rgba(0,0,0,150%)", "hsla(0,0%,0%,2)").forEach { bad -> + try { + CSSColor.parseColor(bad) + assert(false) + } catch (e: Throwable) { + + } + } + } + + @Test fun `out‐of‐range RGB ints clamp`() { + val c = CSSColor.parseColor("rgb(300,-20, 260)") + assertEquals(255, c.red) + assertEquals(0, c.green) + assertEquals(255, c.blue) + } + + @Test fun `parser is case- and whitespace-tolerant`() { + val a = CSSColor.parseColor(" RgB( 10 ,20, 30 )") + assertEquals(10, a.red) + assertEquals(20, a.green) + assertEquals(30, a.blue) + + val b = CSSColor.parseColor(" ReBeCcaPURple ") + assertEquals(0x66, b.red) + assertEquals(0x33, b.green) + assertEquals(0x99, b.blue) + } + + @Test fun `hsl lightness extremes`() { + // lightness = 0 → black + val black = CSSColor.parseColor("hsl(123,45%,0%)") + assertEquals(0, black.red) + assertEquals(0, black.green) + assertEquals(0, black.blue) + // lightness = 100% → white + val white = CSSColor.parseColor("hsl(321,55%,100%)") + assertEquals(255, white.red) + assertEquals(255, white.green) + assertEquals(255, white.blue) + // saturation = 0 → gray (r==g==b) + val gray = CSSColor.parseColor("hsl(50,0%,60%)") + assertTrue(gray.red == gray.green && gray.green == gray.blue) + } + + @Test fun `hsl negative and large hues wrap`() { + val c1 = CSSColor.parseColor("hsl(-120,100%,50%)") // → same as 240° + assertEquals(0, c1.red) + assertEquals(0, c1.green) + assertEquals(255, c1.blue) + + val c2 = CSSColor.parseColor("hsl(480,100%,50%)") // → same as 120° + assertEquals(0, c2.red) + assertEquals(255, c2.green) + assertEquals(0, c2.blue) + } + + @Test fun `lighten then darken returns original`() { + val base = CSSColor.parseColor("#123456") + val round = base.lighten(0.2f).darken(0.2f) + approxEq(base.r, round.r) + approxEq(base.g, round.g) + approxEq(base.b, round.b) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/futo/platformplayer/NoiseProtocolTests.kt b/app/src/test/java/com/futo/platformplayer/NoiseProtocolTests.kt index 189fc767..6e0e3463 100644 --- a/app/src/test/java/com/futo/platformplayer/NoiseProtocolTests.kt +++ b/app/src/test/java/com/futo/platformplayer/NoiseProtocolTests.kt @@ -26,7 +26,7 @@ import java.util.Random import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit - +/* class NoiseProtocolTest { constructor() { Logger.setLogConsumers(listOf( @@ -625,4 +625,4 @@ class NoiseProtocolTest { throw Exception("Byte mismatch at index ${i}") } } -} \ No newline at end of file +}*/ \ No newline at end of file