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"\".*?\"" 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)