Repository: ajalt/mordant (kotlin)

Issue:
Demo Terminal.textAnimation with 256 colors produces an IllegalArgumentException
**Version: 2.0.0-beta3**

Running the demo animation on a 256-color terminal...
```kotlin
        val t = Terminal(ansiLevel = AnsiLevel.ANSI256)
        val a = t.textAnimation<Int> { frame ->
            (1..50).joinToString("") {
                val hue = (frame + it) * 3 % 360
                t.colors.hsv(hue, 100, 100)("━")
            }
        }

        t.cursor.hide(showOnExit = true)
        repeat(120) {
            a.update(it)
            Thread.sleep(25)
        }
```

...produces this exception:
```
Exception in thread "main" java.lang.IllegalArgumentException: Spans cannot contain ANSI codes
        at com.github.ajalt.mordant.rendering.Span$Companion.word(Span.kt:22)
        at com.github.ajalt.mordant.internal.ParsingKt.splitLines(Parsing.kt:90)
        at com.github.ajalt.mordant.internal.ParsingKt.parseText(Parsing.kt:23)
        at com.github.ajalt.mordant.widgets.Text.<init>(Text.kt:29)
        at sca.Application$run$$inlined$textAnimation$default$1.renderData(Animation.kt:89)
        at com.github.ajalt.mordant.animation.Animation.update(Animation.kt:60)
[...]
```

It appears that an escape sequence containing a negative parameter is produced (escape character replaced by `E` for readability):
`E[38;5;-313484m`

Negative values seem to appear initially in the conversion of `HSV(h=3.0, s=100.0, v=100.0, alpha=1.0)` to `SRGB` in `ajalt/colormath/model/HSV.kt` (with `h` normalized to `0.05`):
```kotlin
    override fun toSRGB(): RGB {
        if (s < 1e-7) return RGB(v, v, v, alpha)
        val v = v.toDouble()
        val h = (h.normalizeDeg() / 60.0)
        val s = s.toDouble()

        fun f(n: Int): Float {
            val k = (n + h) % 6
            return (v - v * s * minOf(k, 4 - k, 1.0).coerceAtLeast(0.0)).toFloat()
        }
        return SRGB(f(5), f(3), f(1), alpha)
    }
```

It returns `SRGB(r=100.0, g=-9400.0 b=-9900.0 alpha=1.0)`.

I did not dive into the underlying math, but hopefully this helps.

Pull request:
Fixes #41

Patch:
diff --git a/README.md b/README.md
index 750a38c5f..29c78e13b 100644
--- a/README.md
+++ b/README.md
@@ -191,7 +191,7 @@ val t = Terminal()
 val a = t.textAnimation<Int> { frame ->
     (1..50).joinToString("") {
         val hue = (frame + it) * 3 % 360
-        t.colors.hsv(hue, 100, 100)("━")
+        t.colors.hsv(hue, 1, 1)("━")
     }
 }
 
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt
index 5eaf51e90..8241ad35b 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt
@@ -54,14 +54,14 @@ internal fun downsample(style: TextStyle, level: AnsiLevel, hyperlinks: Boolean)
     return if (style === DEFAULT_STYLE) style else when (level) {
         AnsiLevel.NONE -> DEFAULT_STYLE
         AnsiLevel.ANSI16 -> style.copy(
-            fg = style.color?.toAnsi16(),
-            bg = style.bgColor?.toAnsi16(),
+            fg = style.color?.let { if (it is Ansi16) it else it.toSRGB().clamp().toAnsi16() },
+            bg = style.bgColor?.let { if (it is Ansi16) it else it.toSRGB().clamp().toAnsi16() },
             hyperlink = style.hyperlink.takeIf { hyperlinks },
             hyperlinkId = style.hyperlinkId.takeIf { hyperlinks }
         )
         AnsiLevel.ANSI256 -> style.copy(
-            fg = style.color?.let { if (it is Ansi16) it else it.toAnsi256() },
-            bg = style.bgColor?.let { if (it is Ansi16) it else it.toAnsi256() },
+            fg = style.color?.let { if (it is Ansi16 || it is Ansi256) it else it.toSRGB().clamp().toAnsi256() },
+            bg = style.bgColor?.let { if (it is Ansi16 || it is Ansi256) it else it.toSRGB().clamp().toAnsi256() },
             hyperlink = style.hyperlink.takeIf { hyperlinks },
             hyperlinkId = style.hyperlinkId.takeIf { hyperlinks }
         )
@@ -113,6 +113,6 @@ private fun Color?.toAnsi(select: Int, reset: Int, offset: Int): List<Int> {
         // The ITU T.416 spec uses colons for the rgb separator as well as extra parameters for CMYK
         // and such. Most terminals only support the semicolon form, so that's what we use.
         // https://gist.github.com/XVilka/8346728#gistcomment-2774523
-        else -> it.toSRGB().run { listOf(select, selectorRgb, redInt, greenInt, blueInt) }
+        else -> it.toSRGB().clamp().run { listOf(select, selectorRgb, redInt, greenInt, blueInt) }
     }
 }
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextColors.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextColors.kt
index da9bdc29d..4977ea24e 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextColors.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextColors.kt
@@ -99,37 +99,51 @@ enum class TextColors(
         /**
          * Create a color code from an RGB color.
          *
-         * @param r The red amount, in the range \[0, 255]
-         * @param g The green amount, in the range \[0, 255]
-         * @param b The blue amount, in the range \[0, 255]
+         * @param r The red amount, in the range `[0, 1]`
+         * @param g The green amount, in the range `[0, 1]`
+         * @param b The blue amount, in the range `[0, 1]`
          */
-        fun rgb(r: Int, g: Int, b: Int, level: AnsiLevel = TRUECOLOR): TextStyle = color(RGB.from255(r, g, b), level)
+        fun rgb(r: Number, g: Number, b: Number, level: AnsiLevel = TRUECOLOR): TextStyle {
+            require(r.toFloat() in 0f..1f) { "r must be a number in the range [0, 1]" }
+            require(g.toFloat() in 0f..1f) { "g must be a number in the range [0, 1]" }
+            require(b.toFloat() in 0f..1f) { "b must be a number in the range [0, 1]" }
+
+            return color(RGB(r, g, b), level)
+        }
 
         /**
          * Create a color code from an HSL color.
          *
-         * @param h The hue, in the range \[0, 360]
-         * @param s The saturation, in the range \[0, 100]
-         * @param l The lightness, in the range \[0, 100]
+         * @param h The hue, in the range `[0, 360]`
+         * @param s The saturation, in the range `[0, 1]`
+         * @param l The lightness, in the range `[0, 1]`
          */
-        fun hsl(h: Int, s: Int, l: Int, level: AnsiLevel = TRUECOLOR): TextStyle = color(HSL(h, s, l), level)
+        fun hsl(h: Number, s: Number, l: Number, level: AnsiLevel = TRUECOLOR): TextStyle {
+            require(s.toFloat() in 0f..1f) { "saturation must be a number in the range [0, 1]" }
+            require(l.toFloat() in 0f..1f) { "lightness must be a number in the range [0, 1]" }
+            return color(HSL(h, s, l), level)
+        }
 
         /**
          * Create a color code from an HSV color.
          *
-         * @param h The hue, in the range \[0, 360]
-         * @param s The saturation, in the range \[0,100]
-         * @param v The value, in the range \[0,100]
+         * @param h The hue, in the range `[0, 360]`
+         * @param s The saturation, in the range `[0, 1]`
+         * @param v The value, in the range `[0, 1]`
          */
-        fun hsv(h: Int, s: Int, v: Int, level: AnsiLevel = TRUECOLOR): TextStyle = color(HSV(h, s, v), level)
+        fun hsv(h: Number, s: Number, v: Number, level: AnsiLevel = TRUECOLOR): TextStyle {
+            require(s.toFloat() in 0f..1f) { "saturation must be a number in the range `[0, 1]`" }
+            require(v.toFloat() in 0f..1f) { "value must be a number in the range `[0, 1]`" }
+            return color(HSV(h, s, v), level)
+        }
 
         /**
          * Create a color code from a CMYK color.
          *
-         * @param c The cyan amount, in the range \[0, 100]
-         * @param m The magenta amount, in the range \[0,100]
-         * @param y The yellow amount, in the range \[0,100]
-         * @param k The black amount, in the range \[0,100]
+         * @param c The cyan amount, in the range `[0, 100]`
+         * @param m The magenta amount, in the range `[0, 100]`
+         * @param y The yellow amount, in the range `[0, 100]`
+         * @param k The black amount, in the range `[0, 100]`
          */
         fun cmyk(c: Int, m: Int, y: Int, k: Int, level: AnsiLevel = TRUECOLOR): TextStyle =
             color(CMYK(c, m, y, k), level)
@@ -139,9 +153,9 @@ enum class TextColors(
          *
          * @param fraction The fraction of white in the color. 0 is pure black, 1 is pure white.
          */
-        fun gray(fraction: Double, level: AnsiLevel = TRUECOLOR): TextStyle {
-            require(fraction in 0.0..1.0) { "fraction must be in the range [0, 1]" }
-            return (255 * fraction).roundToInt().let { rgb(it, it, it, level) }
+        fun gray(fraction: Number, level: AnsiLevel = TRUECOLOR): TextStyle {
+            require(fraction.toFloat() in 0f..1f) { "fraction must be in the range [0, 1]" }
+            return  rgb(fraction, fraction, fraction, level)
         }
 
         /**
@@ -149,9 +163,9 @@ enum class TextColors(
          *
          * Conversions use D65 reference white, and sRGB profile.
          *
-         * [x], [y], and [z] are generally in the interval [0, 100], but may be larger
+         * [x], [y], and [z] are generally in the interval `[0, 1]`
          */
-        fun xyz(x: Double, y: Double, z: Double, level: AnsiLevel = TRUECOLOR): TextStyle = color(XYZ(x, y, z), level)
+        fun xyz(x: Number, y: Number, z: Number, level: AnsiLevel = TRUECOLOR): TextStyle = color(XYZ(x, y, z), level)
 
 
         /**
@@ -159,10 +173,10 @@ enum class TextColors(
          *
          * Conversions use D65 reference white, and sRGB profile.
          *
-         * [l] is in the interval [0, 100]. [a] and [b] have unlimited range,
-         * but are generally in [-100, 100]
+         * [l] is in the interval `[0, 100]`. [a] and [b] have unlimited range,
+         * but are generally in `[-100, 100]`
          */
-        fun lab(l: Double, a: Double, b: Double, level: AnsiLevel = TRUECOLOR): TextStyle = color(LAB(l, a, b), level)
+        fun lab(l: Number, a: Number, b: Number, level: AnsiLevel = TRUECOLOR): TextStyle = color(LAB(l, a, b), level)
 
 
         /**
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalColors.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalColors.kt
index 9cfeb15ae..4cb9e2dae 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalColors.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalColors.kt
@@ -97,42 +97,42 @@ class TerminalColors internal constructor(
     val plain: TextStyle get() = DEFAULT_STYLE
 
     /** @param hex An rgb hex string in the form "#ffffff" or "ffffff" */
-    fun rgb(hex: String): TextStyle = color(RGB(hex))
+    fun rgb(hex: String): TextStyle = TextColors.rgb(hex, level)
 
     /**
      * Create a color code from an RGB color.
      *
-     * @param r The red amount, in the range \[0, 255]
-     * @param g The green amount, in the range \[0, 255]
-     * @param b The blue amount, in the range \[0, 255]
+     * @param r The red amount, in the range `[0, 1]`
+     * @param g The green amount, in the range `[0, 1]`
+     * @param b The blue amount, in the range `[0, 1]`
      */
-    fun rgb(r: Int, g: Int, b: Int): TextStyle = TextColors.rgb(r, g, b, level)
+    fun rgb(r: Number, g: Number, b: Number): TextStyle = TextColors.rgb(r, g, b, level)
 
     /**
      * Create a color code from an HSL color.
      *
-     * @param h The hue, in the range \[0, 360]
-     * @param s The saturation, in the range \[0, 100]
-     * @param l The lightness, in the range \[0, 100]
+     * @param h The hue, in the range `[0, 360]`
+     * @param s The saturation, in the range `[0, 1]`
+     * @param l The lightness, in the range `[0, 1]`
      */
-    fun hsl(h: Int, s: Int, l: Int): TextStyle = TextColors.hsl(h, s, l, level)
+    fun hsl(h: Number, s: Number, l: Number): TextStyle = TextColors.hsl(h, s, l, level)
 
     /**
      * Create a color code from an HSV color.
      *
-     * @param h The hue, in the range \[0, 360]
-     * @param s The saturation, in the range \[0,100]
-     * @param v The value, in the range \[0,100]
+     * @param h The hue, in the range `[0, 360]`
+     * @param s The saturation, in the range `[0, 1]`
+     * @param v The value, in the range `[0, 1]`
      */
-    fun hsv(h: Int, s: Int, v: Int): TextStyle = TextColors.hsv(h, s, v, level)
+    fun hsv(h: Number, s: Number, v: Number): TextStyle = TextColors.hsv(h, s, v, level)
 
     /**
      * Create a color code from a CMYK color.
      *
-     * @param c The cyan amount, in the range \[0, 100]
-     * @param m The magenta amount, in the range \[0,100]
-     * @param y The yellow amount, in the range \[0,100]
-     * @param k The black amount, in the range \[0,100]
+     * @param c The cyan amount, in the range `[0, 100]`
+     * @param m The magenta amount, in the range `[0, 100]`
+     * @param y The yellow amount, in the range `[0, 100]`
+     * @param k The black amount, in the range `[0, 100]`
      */
     fun cmyk(c: Int, m: Int, y: Int, k: Int): TextStyle = TextColors.cmyk(c, m, y, k, level)
 
@@ -141,16 +141,16 @@ class TerminalColors internal constructor(
      *
      * @param fraction The fraction of white in the color. 0 is pure black, 1 is pure white.
      */
-    fun gray(fraction: Double): TextStyle = TextColors.gray(fraction, level)
+    fun gray(fraction: Number): TextStyle = TextColors.gray(fraction, level)
 
     /**
      * Create a color code from a CIE XYZ color.
      *
      * Conversions use D65 reference white, and sRGB profile.
      *
-     * [x], [y], and [z] are generally in the interval [0, 100], but may be larger
+     * [x], [y], and [z] are generally in the interval `[0, 1]`
      */
-    fun xyz(x: Double, y: Double, z: Double): TextStyle = TextColors.xyz(x, y, z, level)
+    fun xyz(x: Number, y: Number, z: Number): TextStyle = TextColors.xyz(x, y, z, level)
 
 
     /**
@@ -158,10 +158,10 @@ class TerminalColors internal constructor(
      *
      * Conversions use D65 reference white, and sRGB profile.
      *
-     * [l] is in the interval [0, 100]. [a] and [b] have unlimited range,
-     * but are generally in [-100, 100]
+     * [l] is in the interval `[0, 100]`. [a] and [b] have unlimited range,
+     * but are generally in `[-100, 100]`
      */
-    fun lab(l: Double, a: Double, b: Double): TextStyle = TextColors.lab(l, a, b, level)
+    fun lab(l: Number, a: Number, b: Number): TextStyle = TextColors.lab(l, a, b, level)
 
     /**
      * Create a [TextStyle] with a foreground of [color], downsampled to a given [level].


Test patch:
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt
index d3fcc304a..be12d3f0b 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt
@@ -97,13 +97,13 @@ class TerminalColorsTest {
     @JsName("all_24bit_colors")
     fun `all 24bit colors`() = forAll(
         row(c.rgb("#ff00ff"), RGB("#ff00ff")),
-        row(c.rgb(11, 22, 33), RGB.from255(11, 22, 33)),
-        row(c.hsl(11, 22, 33), HSL(11, 22, 33)),
-        row(c.hsv(11, 22, 33), HSV(11, 22, 33)),
+        row(c.rgb(.11, .22, .33), RGB(.11, .22, .33)),
+        row(c.hsl(.11, .22, .33), HSL(.11, .22, .33)),
+        row(c.hsv(11, .22, .33), HSV(11, .22, .33)),
         row(c.cmyk(11, 22, 33, 44), CMYK(11, 22, 33, 44)),
-        row(c.gray(0.5), RGB.from255(128, 128, 128)),
-        row(c.xyz(11.0, 22.0, 33.0), XYZ(11.0, 22.0, 33.0)),
-        row(c.lab(11.0, 22.0, 33.0), LAB(11.0, 22.0, 33.0)),
+        row(c.gray(0.5), RGB(.5, .5, .5)),
+        row(c.xyz(.11, .22, .33), XYZ(.11, .22, .33)),
+        row(c.lab(11, 22, 33), LAB(11, 22, 33)),
     ) { color, expected ->
         color.color shouldBe expected
     }