Repository: Textualize/textual

Issue:
Support HSL and HSLA colors in CSS
Suspect this is fairly easy todo, and a nice feature.

Discussion:

Patch:
diff --git a/sandbox/hsl.py b/sandbox/hsl.py
new file mode 100644
index 000000000..17c0d4f49
--- /dev/null
+++ b/sandbox/hsl.py
@@ -0,0 +1,11 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Static
+
+
+class HSLApp(App):
+    def compose(self) -> ComposeResult:
+        yield Static(classes="box")
+
+
+app = HSLApp(css_path="hsl.scss", watch_css=True)
+app.run()
diff --git a/sandbox/hsl.scss b/sandbox/hsl.scss
new file mode 100644
index 000000000..78c0a495f
--- /dev/null
+++ b/sandbox/hsl.scss
@@ -0,0 +1,5 @@
+.box {
+  height: 1fr;
+  /*background: rgb(180,50, 50);*/
+  background: hsl(180,50%, 50%);
+}
diff --git a/src/textual/color.py b/src/textual/color.py
index fd7fd0bd8..67b5bbe44 100644
--- a/src/textual/color.py
+++ b/src/textual/color.py
@@ -23,6 +23,8 @@ from rich.color import Color as RichColor
 from rich.style import Style
 from rich.text import Text
 
+from textual.css.scalar import percentage_string_to_float
+from textual.css.tokenize import COMMA, OPEN_BRACE, CLOSE_BRACE, DECIMAL, PERCENT
 from textual.suggestions import get_suggestion
 from ._color_constants import COLOR_NAME_TO_RGB
 from .geometry import clamp
@@ -53,13 +55,15 @@ class Lab(NamedTuple):
 
 
 RE_COLOR = re.compile(
-    r"""^
-\#([0-9a-fA-F]{3})$|
-\#([0-9a-fA-F]{4})$|
-\#([0-9a-fA-F]{6})$|
-\#([0-9a-fA-F]{8})$|
-rgb\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$|
-rgba\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$
+    rf"""^
+\#([0-9a-fA-F]{{3}})$|
+\#([0-9a-fA-F]{{4}})$|
+\#([0-9a-fA-F]{{6}})$|
+\#([0-9a-fA-F]{{8}})$|
+rgb{OPEN_BRACE}({DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}){CLOSE_BRACE}$|
+rgba{OPEN_BRACE}({DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}){CLOSE_BRACE}$|
+hsl{OPEN_BRACE}({DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}){CLOSE_BRACE}$|
+hsla{OPEN_BRACE}({DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}{COMMA}{DECIMAL}){CLOSE_BRACE}$
 """,
     re.VERBOSE,
 )
@@ -284,7 +288,7 @@ class Color(NamedTuple):
         if color_match is None:
             error_message = f"failed to parse {color_text!r} as a color"
             suggested_color = None
-            if not color_text.startswith("#") and not color_text.startswith("rgb"):
+            if not color_text.startswith(("#", "rgb", "hsl")):
                 # Seems like we tried to use a color name: let's try to find one that is close enough:
                 suggested_color = get_suggestion(color_text, COLOR_NAME_TO_RGB.keys())
                 if suggested_color:
@@ -297,6 +301,8 @@ class Color(NamedTuple):
             rgba_hex,
             rgb,
             rgba,
+            hsl,
+            hsla,
         ) = color_match.groups()
 
         if rgb_hex_triple is not None:
@@ -329,6 +335,19 @@ class Color(NamedTuple):
                 clamp(int(float_b), 0, 255),
                 clamp(float_a, 0.0, 1.0),
             )
+        elif hsl is not None:
+            h, s, l = hsl.split(",")
+            h = float(h) % 360 / 360
+            s = percentage_string_to_float(s)
+            l = percentage_string_to_float(l)
+            color = Color.from_hls(h, l, s)
+        elif hsla is not None:
+            h, s, l, a = hsla.split(",")
+            h = float(h) % 360 / 360
+            s = percentage_string_to_float(s)
+            l = percentage_string_to_float(l)
+            a = clamp(float(a), 0.0, 1.0)
+            color = Color.from_hls(h, l, s).with_alpha(a)
         else:
             raise AssertionError("Can't get here if RE_COLOR matches")
         return color
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index c9d4e40cb..b0d390573 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -40,7 +40,14 @@ from .constants import (
 )
 from .errors import DeclarationError, StyleValueError
 from .model import Declaration
-from .scalar import Scalar, ScalarOffset, Unit, ScalarError, ScalarParseError
+from .scalar import (
+    Scalar,
+    ScalarOffset,
+    Unit,
+    ScalarError,
+    ScalarParseError,
+    percentage_string_to_float,
+)
 from .styles import DockGroup, Styles
 from .tokenize import Token
 from .transition import Transition
@@ -333,9 +340,8 @@ class StylesBuilder:
             token_name = token.name
             value = token.value
             if token_name == "scalar" and value.endswith("%"):
-                percentage = value[:-1]
                 try:
-                    opacity = clamp(float(percentage) / 100, 0, 1)
+                    opacity = percentage_string_to_float(value)
                     self.styles.set_rule(name, opacity)
                 except ValueError:
                     error = True
diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py
index 7e349e785..dd612d09d 100644
--- a/src/textual/css/scalar.py
+++ b/src/textual/css/scalar.py
@@ -8,7 +8,7 @@ from typing import Iterable, NamedTuple
 
 import rich.repr
 
-from ..geometry import Offset, Size
+from ..geometry import Offset, Size, clamp
 
 
 class ScalarError(Exception):
@@ -326,6 +326,22 @@ class ScalarOffset(NamedTuple):
 
 NULL_SCALAR = ScalarOffset(Scalar.from_number(0), Scalar.from_number(0))
 
+
+def percentage_string_to_float(string: str) -> float:
+    """Convert a string percentage e.g. '20%' to a float e.g. 20.0.
+
+    Args:
+        string (str): The percentage string to convert.
+    """
+    string = string.strip()
+    if string.endswith("%"):
+        percentage = string[:-1]
+        float_percentage = clamp(float(percentage) / 100, 0, 1)
+    else:
+        float_percentage = float(string)
+    return float_percentage
+
+
 if __name__ == "__main__":
     print(Scalar.parse("3.14fr"))
     s = Scalar.parse("23")
diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py
index dff909c4b..7cee24ce2 100644
--- a/src/textual/css/tokenize.py
+++ b/src/textual/css/tokenize.py
@@ -6,11 +6,21 @@ from typing import Iterable
 
 from textual.css.tokenizer import Expect, Tokenizer, Token
 
+PERCENT = r"-?\d+\.?\d*%"
+DECIMAL = r"-?\d+\.?\d*"
+COMMA = r"\s*,\s*"
+OPEN_BRACE = r"\(\s*"
+CLOSE_BRACE = r"\s*\)"
+
+HEX_COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|\#[0-9a-fA-F]{4}|\#[0-9a-fA-F]{3}"
+RGB_COLOR = rf"rgb{OPEN_BRACE}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{CLOSE_BRACE}|rgba{OPEN_BRACE}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{CLOSE_BRACE}"
+HSL_COLOR = rf"hsl{OPEN_BRACE}{DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}{CLOSE_BRACE}|hsla{OPEN_BRACE}{DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}{COMMA}{DECIMAL}{CLOSE_BRACE}"
+
 COMMENT_START = r"\/\*"
-SCALAR = r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)"
+SCALAR = rf"{DECIMAL}(?:fr|%|w|h|vw|vh)"
 DURATION = r"\d+\.?\d*(?:ms|s)"
 NUMBER = r"\-?\d+\.?\d*"
-COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|\#[0-9a-fA-F]{4}|\#[0-9a-fA-F]{3}|rgb\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)|rgba\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)"
+COLOR = rf"{HEX_COLOR}|{RGB_COLOR}|{HSL_COLOR}"
 KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+"
 TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*"
 STRING = r"\".*?\""


Test patch:
diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py
index dd1e73809..b9b03f3bf 100644
--- a/tests/css/test_parse.py
+++ b/tests/css/test_parse.py
@@ -904,6 +904,32 @@ class TestParseText:
         assert styles.background == Color.parse("red")
 
 
+class TestParseColor:
+    """More in-depth tests around parsing of CSS colors"""
+
+    @pytest.mark.parametrize("value,result", [
+        ("rgb(1,255,50)", Color(1, 255, 50)),
+        ("rgb( 1, 255,50 )", Color(1, 255, 50)),
+        ("rgba( 1, 255,50,0.3 )", Color(1, 255, 50, 0.3)),
+        ("rgba( 1, 255,50, 1.3 )", Color(1, 255, 50, 1.0)),
+        ("hsl( 180, 50%, 50% )", Color(64, 191, 191)),
+        ("hsl(180,50%,50%)", Color(64, 191, 191)),
+        ("hsla(180,50%,50%,0.25)", Color(64, 191, 191, 0.25)),
+        ("hsla( 180, 50% ,50%,0.25 )", Color(64, 191, 191, 0.25)),
+        ("hsla( 180, 50% , 50% , 1.5 )", Color(64, 191, 191)),
+    ])
+    def test_rgb_and_hsl(self, value, result):
+        css = f""".box {{
+          color: {value};
+        }}
+        """
+        stylesheet = Stylesheet()
+        stylesheet.add_source(css)
+
+        styles = stylesheet.rules[0].styles
+        assert styles.color == result
+
+
 class TestParseOffset:
     @pytest.mark.parametrize(
         "offset_x, parsed_x, offset_y, parsed_y",
diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py
index 9ef433a29..7dca722cc 100644
--- a/tests/css/test_stylesheet.py
+++ b/tests/css/test_stylesheet.py
@@ -32,9 +32,6 @@ from textual.css.tokenizer import TokenizeError
         ["red 4", pytest.raises(StylesheetParseError), None],  # space in it
         ["1", pytest.raises(StylesheetParseError), None],  # invalid value
         ["()", pytest.raises(TokenizeError), None],  # invalid tokens
-        # TODO: allow spaces in rgb/rgba expressions?
-        ["rgb(200, 90, 30)", pytest.raises(TokenizeError), None],
-        ["rgba(200,90,30, 0.4)", pytest.raises(TokenizeError), None],
     ],
 )
 def test_color_property_parsing(css_value, expectation, expected_color):
diff --git a/tests/test_color.py b/tests/test_color.py
index 97ffddd39..3f9719665 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -1,5 +1,4 @@
 import pytest
-
 from rich.color import Color as RichColor
 from rich.text import Text
 
@@ -108,12 +107,43 @@ def test_color_blend():
         ("rgb(2,3,4)", Color(2, 3, 4, 1.0)),
         ("rgba(2,3,4,1.0)", Color(2, 3, 4, 1.0)),
         ("rgba(2,3,4,0.058823529411764705)", Color(2, 3, 4, 0.058823529411764705)),
+        ("hsl(45,25%,25%)", Color(80, 72, 48)),
+        ("hsla(45,25%,25%,0.35)", Color(80, 72, 48, 0.35)),
     ],
 )
 def test_color_parse(text, expected):
     assert Color.parse(text) == expected
 
 
+@pytest.mark.parametrize("input,output", [
+    ("rgb( 300, 300 , 300 )", Color(255, 255, 255)),
+    ("rgba( 2 , 3 , 4, 1.0 )", Color(2, 3, 4, 1.0)),
+    ("hsl( 45, 25% , 25% )", Color(80, 72, 48)),
+    ("hsla( 45, 25% , 25%, 0.35 )", Color(80, 72, 48, 0.35)),
+])
+def test_color_parse_input_has_spaces(input, output):
+    assert Color.parse(input) == output
+
+
+@pytest.mark.parametrize("input,output", [
+    ("rgb(300, 300, 300)", Color(255, 255, 255)),
+    ("rgba(300, 300, 300, 300)", Color(255, 255, 255, 1.0)),
+    ("hsl(400, 200%, 250%)", Color(255, 255, 255, 1.0)),
+    ("hsla(400, 200%, 250%, 1.9)", Color(255, 255, 255, 1.0)),
+])
+def test_color_parse_clamp(input, output):
+    assert Color.parse(input) == output
+
+
+def test_color_parse_hsl_negative_degrees():
+    assert Color.parse("hsl(-90, 50%, 50%)") == Color.parse("hsl(270, 50%, 50%)")
+
+
+def test_color_parse_hsla_negative_degrees():
+    assert Color.parse("hsla(-45, 50%, 50%, 0.2)") == Color.parse(
+        "hsla(315, 50%, 50%, 0.2)")
+
+
 def test_color_parse_color():
     # as a convenience, if Color.parse is passed a color object, it will return it
     color = Color(20, 30, 40, 0.5)