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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"\".*?\""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user