Merge pull request #566 from Textualize/hsl

Support HSL colour space, allow spaces in RGB/HSL values
This commit is contained in:
Will McGugan
2022-06-08 16:25:15 +01:00
committed by GitHub
9 changed files with 138 additions and 18 deletions

11
sandbox/hsl.py Normal file
View File

@@ -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()

5
sandbox/hsl.scss Normal file
View File

@@ -0,0 +1,5 @@
.box {
height: 1fr;
/*background: rgb(180,50, 50);*/
background: hsl(180,50%, 50%);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

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

View File

@@ -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",

View File

@@ -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):

View File

@@ -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)