mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #566 from Textualize/hsl
Support HSL colour space, allow spaces in RGB/HSL values
This commit is contained in:
11
sandbox/hsl.py
Normal file
11
sandbox/hsl.py
Normal 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
5
sandbox/hsl.scss
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.box {
|
||||||
|
height: 1fr;
|
||||||
|
/*background: rgb(180,50, 50);*/
|
||||||
|
background: hsl(180,50%, 50%);
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ from rich.color import Color as RichColor
|
|||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
from rich.text import Text
|
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 textual.suggestions import get_suggestion
|
||||||
from ._color_constants import COLOR_NAME_TO_RGB
|
from ._color_constants import COLOR_NAME_TO_RGB
|
||||||
from .geometry import clamp
|
from .geometry import clamp
|
||||||
@@ -53,13 +55,15 @@ class Lab(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
RE_COLOR = re.compile(
|
RE_COLOR = re.compile(
|
||||||
r"""^
|
rf"""^
|
||||||
\#([0-9a-fA-F]{3})$|
|
\#([0-9a-fA-F]{{3}})$|
|
||||||
\#([0-9a-fA-F]{4})$|
|
\#([0-9a-fA-F]{{4}})$|
|
||||||
\#([0-9a-fA-F]{6})$|
|
\#([0-9a-fA-F]{{6}})$|
|
||||||
\#([0-9a-fA-F]{8})$|
|
\#([0-9a-fA-F]{{8}})$|
|
||||||
rgb\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$|
|
rgb{OPEN_BRACE}({DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}){CLOSE_BRACE}$|
|
||||||
rgba\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$
|
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,
|
re.VERBOSE,
|
||||||
)
|
)
|
||||||
@@ -284,7 +288,7 @@ class Color(NamedTuple):
|
|||||||
if color_match is None:
|
if color_match is None:
|
||||||
error_message = f"failed to parse {color_text!r} as a color"
|
error_message = f"failed to parse {color_text!r} as a color"
|
||||||
suggested_color = None
|
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:
|
# 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())
|
suggested_color = get_suggestion(color_text, COLOR_NAME_TO_RGB.keys())
|
||||||
if suggested_color:
|
if suggested_color:
|
||||||
@@ -297,6 +301,8 @@ class Color(NamedTuple):
|
|||||||
rgba_hex,
|
rgba_hex,
|
||||||
rgb,
|
rgb,
|
||||||
rgba,
|
rgba,
|
||||||
|
hsl,
|
||||||
|
hsla,
|
||||||
) = color_match.groups()
|
) = color_match.groups()
|
||||||
|
|
||||||
if rgb_hex_triple is not None:
|
if rgb_hex_triple is not None:
|
||||||
@@ -329,6 +335,19 @@ class Color(NamedTuple):
|
|||||||
clamp(int(float_b), 0, 255),
|
clamp(int(float_b), 0, 255),
|
||||||
clamp(float_a, 0.0, 1.0),
|
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:
|
else:
|
||||||
raise AssertionError("Can't get here if RE_COLOR matches")
|
raise AssertionError("Can't get here if RE_COLOR matches")
|
||||||
return color
|
return color
|
||||||
|
|||||||
@@ -40,7 +40,14 @@ from .constants import (
|
|||||||
)
|
)
|
||||||
from .errors import DeclarationError, StyleValueError
|
from .errors import DeclarationError, StyleValueError
|
||||||
from .model import Declaration
|
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 .styles import DockGroup, Styles
|
||||||
from .tokenize import Token
|
from .tokenize import Token
|
||||||
from .transition import Transition
|
from .transition import Transition
|
||||||
@@ -333,9 +340,8 @@ class StylesBuilder:
|
|||||||
token_name = token.name
|
token_name = token.name
|
||||||
value = token.value
|
value = token.value
|
||||||
if token_name == "scalar" and value.endswith("%"):
|
if token_name == "scalar" and value.endswith("%"):
|
||||||
percentage = value[:-1]
|
|
||||||
try:
|
try:
|
||||||
opacity = clamp(float(percentage) / 100, 0, 1)
|
opacity = percentage_string_to_float(value)
|
||||||
self.styles.set_rule(name, opacity)
|
self.styles.set_rule(name, opacity)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
error = True
|
error = True
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Iterable, NamedTuple
|
|||||||
|
|
||||||
import rich.repr
|
import rich.repr
|
||||||
|
|
||||||
from ..geometry import Offset, Size
|
from ..geometry import Offset, Size, clamp
|
||||||
|
|
||||||
|
|
||||||
class ScalarError(Exception):
|
class ScalarError(Exception):
|
||||||
@@ -326,6 +326,22 @@ class ScalarOffset(NamedTuple):
|
|||||||
|
|
||||||
NULL_SCALAR = ScalarOffset(Scalar.from_number(0), Scalar.from_number(0))
|
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__":
|
if __name__ == "__main__":
|
||||||
print(Scalar.parse("3.14fr"))
|
print(Scalar.parse("3.14fr"))
|
||||||
s = Scalar.parse("23")
|
s = Scalar.parse("23")
|
||||||
|
|||||||
@@ -6,11 +6,21 @@ from typing import Iterable
|
|||||||
|
|
||||||
from textual.css.tokenizer import Expect, Tokenizer, Token
|
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"\/\*"
|
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)"
|
DURATION = r"\d+\.?\d*(?:ms|s)"
|
||||||
NUMBER = r"\-?\d+\.?\d*"
|
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_\-\/]+"
|
KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+"
|
||||||
TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*"
|
TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*"
|
||||||
STRING = r"\".*?\""
|
STRING = r"\".*?\""
|
||||||
|
|||||||
@@ -904,6 +904,32 @@ class TestParseText:
|
|||||||
assert styles.background == Color.parse("red")
|
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:
|
class TestParseOffset:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"offset_x, parsed_x, offset_y, parsed_y",
|
"offset_x, parsed_x, offset_y, parsed_y",
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ from textual.css.tokenizer import TokenizeError
|
|||||||
["red 4", pytest.raises(StylesheetParseError), None], # space in it
|
["red 4", pytest.raises(StylesheetParseError), None], # space in it
|
||||||
["1", pytest.raises(StylesheetParseError), None], # invalid value
|
["1", pytest.raises(StylesheetParseError), None], # invalid value
|
||||||
["()", pytest.raises(TokenizeError), None], # invalid tokens
|
["()", 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):
|
def test_color_property_parsing(css_value, expectation, expected_color):
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from rich.color import Color as RichColor
|
from rich.color import Color as RichColor
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
@@ -108,12 +107,43 @@ def test_color_blend():
|
|||||||
("rgb(2,3,4)", Color(2, 3, 4, 1.0)),
|
("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,1.0)", Color(2, 3, 4, 1.0)),
|
||||||
("rgba(2,3,4,0.058823529411764705)", Color(2, 3, 4, 0.058823529411764705)),
|
("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):
|
def test_color_parse(text, expected):
|
||||||
assert 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():
|
def test_color_parse_color():
|
||||||
# as a convenience, if Color.parse is passed a color object, it will return it
|
# as a convenience, if Color.parse is passed a color object, it will return it
|
||||||
color = Color(20, 30, 40, 0.5)
|
color = Color(20, 30, 40, 0.5)
|
||||||
|
|||||||
Reference in New Issue
Block a user