New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[colors] Add a "auto" color #596
Changes from all commits
1cf8a5e
b9662c7
28edacf
94ba806
4439ca1
ea1a429
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
""" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're doing some custom parsing here, which is different from the parsing done in the CSS. I'd like to avoid solving the same problem twice. We could use Here's an example: >>> from textual.css.tokenize import tokenize_value
>>> list(tokenize_value("hsl( 180, 50%, 50% ) 30%", "expression"))
[Token(name='color', value='hsl( 180, 50%, 50% )', path='expression', code='hsl( 180, 50%, 50% ) 30%', location=(0, 0)), Token(name='whitespace', value=' ', path='expression', code='hsl( 180, 50%, 50% ) 30%', location=(0, 20)), Token(name='scalar', value='30%', path='expression', code='hsl( 180, 50%, 50% ) 30%', location=(0, 21))] This should return the same tokens passed to the builder object. How about the property uses There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My reasoning was that simply doing a |
||
Helper functions to work with Textual Colors. | ||
""" | ||
|
||
from __future__ import annotations | ||
|
||
from textual.color import Color | ||
from textual.css._help_text import color_property_help_text | ||
from textual.css.errors import DeclarationError, StyleValueError | ||
from textual.css.scalar import Scalar, Unit | ||
from textual.css.tokenizer import Token | ||
|
||
|
||
def parse_color_tokens(property_name: str, tokens: list[Token]) -> tuple[Color, bool]: | ||
""" | ||
Parses the tokens of a color expression, such as "red", "#FFCC00", "rgb(1,255,50)", "lime 50%", "95% auto"... | ||
|
||
Args: | ||
property_name (str): The name of the CSS property. Only used to raise DeclarationErrors that include this name. | ||
tokens (list[Token]): CSS tokens describing a color - i.e. an expression supported by ``Color.parse``, | ||
optionally prefixed or suffixed with a percentage value token. | ||
|
||
Returns: | ||
tuple[Color, bool]: a Color instance, as well as a boolean telling us if it's a pseudo-color "auto" one. | ||
|
||
Raises: | ||
DeclarationError: if we come across an unexpected value | ||
""" | ||
property_name = property_name.replace("-", "_") | ||
|
||
alpha = 1.0 | ||
color = None | ||
is_auto = False | ||
|
||
for token in tokens: | ||
if token.name == "token" and token.value == "auto": | ||
is_auto = True | ||
elif token.name == "scalar": | ||
alpha_scalar = Scalar.parse(token.value) | ||
if alpha_scalar.unit != Unit.PERCENT: | ||
raise DeclarationError( | ||
property_name, token, "alpha must be given as a percentage." | ||
) | ||
alpha = alpha_scalar.value / 100.0 | ||
elif token.name in ("color", "token"): | ||
try: | ||
color = Color.parse(token.value) | ||
except Exception as error: | ||
raise DeclarationError( | ||
property_name, | ||
token, | ||
color_property_help_text(property_name, context="css", error=error), | ||
) | ||
elif token.name == "whitespace": | ||
continue | ||
else: | ||
raise DeclarationError( | ||
property_name, | ||
token, | ||
color_property_help_text(property_name, context="css"), | ||
) | ||
|
||
if is_auto: | ||
# For "auto" colors we're still using a Color object, but only to store their alpha. | ||
# We could use anything for the RGB values since we won't use them, but using (1,2,3) is handy to | ||
# be able to spot such "I'm just an alpha bearer" Color instances when we debug: | ||
return Color(1, 2, 3, alpha), True | ||
|
||
if color is None: | ||
raise StyleValueError( | ||
f"no Color could be extracted from the given expression for property '{property_name}'." | ||
) | ||
|
||
if alpha is not None and alpha < 1: | ||
color = color.with_alpha(alpha) | ||
|
||
return color, False |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,11 +9,12 @@ | |
|
||
from __future__ import annotations | ||
|
||
from typing import Generic, Iterable, NamedTuple, TypeVar, TYPE_CHECKING, cast | ||
from typing import Generic, Iterable, NamedTuple, TypeVar, TYPE_CHECKING, cast, ClassVar | ||
|
||
import rich.repr | ||
from rich.style import Style | ||
|
||
from ._color import parse_color_tokens | ||
from ._help_text import ( | ||
border_property_help_text, | ||
layout_property_help_text, | ||
|
@@ -27,11 +28,12 @@ | |
string_enum_help_text, | ||
color_property_help_text, | ||
) | ||
from .._border import INVISIBLE_EDGE_TYPES, normalize_border_value | ||
from .tokenize import tokenize_value | ||
from .._border import normalize_border_value | ||
from ..color import Color, ColorPair, ColorParseError | ||
from ._error_tools import friendly_list | ||
from .constants import NULL_SPACING, VALID_STYLE_FLAGS | ||
from .errors import StyleTypeError, StyleValueError | ||
from .errors import StyleTypeError, StyleValueError, DeclarationError | ||
from .scalar import ( | ||
get_symbols, | ||
UNIT_SYMBOL, | ||
|
@@ -797,6 +799,8 @@ def __set__(self, obj: StylesBase, names: str | tuple[str] | None = None): | |
class ColorProperty: | ||
"""Descriptor for getting and setting color properties.""" | ||
|
||
_supports_auto: ClassVar = False | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could do without this class var, but if I do so there will be quite a lot of code duplication between the |
||
|
||
def __init__(self, default_color: Color | str) -> None: | ||
self._default_color = Color.parse(default_color) | ||
|
||
|
@@ -837,21 +841,93 @@ def __set__(self, obj: StylesBase, color: Color | str | None): | |
if obj.set_rule(self.name, color): | ||
obj.refresh() | ||
elif isinstance(color, str): | ||
tokens = tokenize_value(color, "expression") | ||
try: | ||
parsed_color = Color.parse(color) | ||
parsed_color, is_auto = parse_color_tokens(self.name, tokens) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can raise This would be entirely unexpected in this context. It should raise |
||
except ColorParseError as error: | ||
raise StyleValueError( | ||
f"Invalid color value '{color}'", | ||
help_text=color_property_help_text( | ||
self.name, context="inline", error=error | ||
), | ||
) | ||
except DeclarationError as error: | ||
raise StyleValueError( | ||
f"Invalid color value '{color}': {error.message}", | ||
help_text=color_property_help_text( | ||
self.name, context="inline", error=error | ||
), | ||
) | ||
|
||
if is_auto: | ||
if not self._supports_auto: | ||
raise StyleValueError( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added a test to check that this does its job 👇 |
||
f"Invalid color value '{color}': cannot use 'auto' for this property", | ||
help_text=color_property_help_text(self.name, context="inline"), | ||
) | ||
else: | ||
obj._color_auto = True | ||
if obj.set_rule(self.name, parsed_color): | ||
obj.refresh() | ||
else: | ||
raise StyleValueError(f"Invalid color value {color}") | ||
|
||
|
||
class ForegroundColorProperty(ColorProperty): | ||
"""Descriptor for getting and setting color properties, also able to manage "auto" colors.""" | ||
|
||
_supports_auto = True | ||
|
||
def __get__( | ||
self, obj: StylesBase, objtype: type[StylesBase] | None = None | ||
) -> Color: | ||
"""Get a ``Color``. | ||
|
||
If the property is "color" and the styles have a "color_auto", this value will be dynamically calculated | ||
by taking into account the background colors of the attached DOMNode's ancestors. | ||
|
||
Args: | ||
obj (Styles): The ``Styles`` object. | ||
objtype (type[Styles]): The ``Styles`` class. | ||
|
||
Returns: | ||
Color: The Color | ||
""" | ||
if not obj.color_auto: | ||
# Simple (and main) case: we just return the color stored in the Style rule: | ||
return cast(Color, obj.get_rule(self.name, self._default_color)) | ||
|
||
# We're dealing with a "color" property that has an "auto" value: let's traverse the Node ancestors to | ||
# calculate what the resulting value should be. | ||
from ..dom import DOMNode | ||
|
||
styles_node = getattr(obj, "node", None) | ||
if not isinstance(styles_node, DOMNode): | ||
raise ValueError( | ||
"Dynamic 'auto' color can only be used with an subclass of StylesBase that has a 'node' attribute" | ||
) | ||
# We just repeat what `DOMNode.color` does, in a slightly simplified way. | ||
# N.B. `DOMNode.color` will call this `__get__` method while it is itself traversing the DOMNode ancestors, | ||
# so we loop over ancestors in a loop that is itself looping over ancestors. | ||
# That's a deliberate implementation choice. | ||
background = Color(0, 0, 0, 0) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this dynamic resolution is now only made for the "color" property |
||
color = Color(255, 255, 255, 0) | ||
# N.B. In this loop we're using `styles.get_rule("color")` rather than `styles.color` | ||
# in order to avoid infinite recursion. | ||
for node in reversed(styles_node.ancestors): | ||
styles = node.styles | ||
if styles.has_rule("background"): | ||
background += styles.background | ||
if styles.color_auto: | ||
# The alpha of our "color_auto" color is stored in the "color" property | ||
# (which in this case is used only to bear that value; its RGB values should not be taken into account) | ||
color_auto_alpha = styles.get_rule("color").a | ||
color = background.get_contrast_text(color_auto_alpha) | ||
elif styles.has_rule("color"): | ||
color += styles.get_rule("color") # styles.color | ||
return color | ||
|
||
|
||
class StyleFlagsProperty: | ||
"""Descriptor for getting and set style flag properties (e.g. ``bold italic underline``).""" | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -907,18 +907,23 @@ def test_background(self): | |
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): | ||
@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)), | ||
("red 50%", Color(255, 0, 0, 0.5)), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I just added these 2 cases, and Black did the rest 😄 |
||
("30% rgb(1,255,50)", Color(1, 255, 50, 0.3)), | ||
], | ||
) | ||
def test_complex_expressions(self, value, result): | ||
css = f""".box {{ | ||
color: {value}; | ||
}} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This "is_dark_color" test is no longer required, since the new
auto
property does exactly this 😊The result: