Skip to content
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

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 1 addition & 3 deletions sandbox/color_names.py
Expand Up @@ -7,7 +7,6 @@
from textual._color_constants import COLOR_NAME_TO_RGB
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual.widgets import Placeholder


@rich.repr.auto(angular=False)
Expand Down Expand Up @@ -35,8 +34,7 @@ def on_mount(self):
def compose(self) -> ComposeResult:
for color_name, color in COLOR_NAME_TO_RGB.items():
color_placeholder = ColorDisplay(name=color_name)
is_dark_color = sum(color) < 400
color_placeholder.styles.color = "white" if is_dark_color else "black"
color_placeholder.styles.color = "auto 90%"
Copy link
Author

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:
Screenshot from 2022-06-29 14-33-56

color_placeholder.styles.background = color_name
yield color_placeholder

Expand Down
77 changes: 77 additions & 0 deletions src/textual/css/_color.py
@@ -0,0 +1,77 @@
"""
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 tokenize_value in textual.css.tokenize for this -- which is what the CSS parser uses.

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 tokenize and calls a method to extract a Color from the tokens. The builder could also call this same method.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My reasoning was that simply doing a join over the tokens in the StylesBuilder was much faster than tokenizing the values in the ColorProperty - and the 2 regexes used to find the percentage values likely have a fast lookup since they only search at the beginning and the end of a string.
But ok, I'll do that 🙂

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
84 changes: 80 additions & 4 deletions src/textual/css/_style_properties.py
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Copy link
Author

Choose a reason for hiding this comment

The 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 __get__ methods of the ColorProperty and the new ForegroundColorProperty.
As always, it's a trade-off 😅


def __init__(self, default_color: Color | str) -> None:
self._default_color = Color.parse(default_color)

Expand Down Expand Up @@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can raise DeclarationErrors can't it?

This would be entirely unexpected in this context. It should raise StyleValueErrors

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(
Copy link
Author

Choose a reason for hiding this comment

The 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)
Copy link
Author

Choose a reason for hiding this comment

The 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``)."""

Expand Down
35 changes: 10 additions & 25 deletions src/textual/css/_styles_builder.py
Expand Up @@ -5,6 +5,7 @@

import rich.repr

from ._color import parse_color_tokens
from ._error_tools import friendly_list
from ._help_renderables import HelpText
from ._help_text import (
Expand Down Expand Up @@ -569,32 +570,16 @@ def process_color(self, name: str, tokens: list[Token]) -> None:
"""Processes a simple color declaration."""
name = name.replace("-", "_")

color: Color | None = None
alpha: float | None = None
parsed_color, is_auto = parse_color_tokens(name, tokens)

for token in tokens:
if token.name == "scalar":
alpha_scalar = Scalar.parse(token.value)
if alpha_scalar.unit != Unit.PERCENT:
self.error(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:
self.error(
name,
token,
color_property_help_text(name, context="css", error=error),
)
else:
self.error(name, token, color_property_help_text(name, context="css"))

if color is not None:
if alpha is not None:
color = color.with_alpha(alpha)
self.styles._rules[name] = color
if is_auto:
if name != "color":
self.error(
name, "auto", "'auto' can only be set for the 'color' property."
)
self.styles._color_auto = True
if parsed_color is not None:
self.styles._rules[name] = parsed_color

process_tint = process_color
process_background = process_color
Expand Down
2 changes: 1 addition & 1 deletion src/textual/css/errors.py
Expand Up @@ -8,7 +8,7 @@


class DeclarationError(Exception):
def __init__(self, name: str, token: Token, message: str) -> None:
def __init__(self, name: str, token: Token, message: str | HelpText) -> None:
self.name = name
self.token = token
self.message = message
Expand Down
16 changes: 13 additions & 3 deletions src/textual/css/styles.py
Expand Up @@ -32,6 +32,7 @@
StyleProperty,
TransitionsProperty,
FractionalProperty,
ForegroundColorProperty,
)
from .constants import (
VALID_ALIGN_HORIZONTAL,
Expand All @@ -52,7 +53,6 @@
AlignHorizontal,
Overflow,
Specificity3,
Specificity4,
AlignVertical,
Visibility,
ScrollbarGutter,
Expand Down Expand Up @@ -181,7 +181,7 @@ class StylesBase(ABC):
visibility = StringEnumProperty(VALID_VISIBILITY, "visible")
layout = LayoutProperty()

color = ColorProperty(Color(255, 255, 255))
color = ForegroundColorProperty(Color(255, 255, 255))
background = ColorProperty(Color(0, 0, 0, 0))
text_style = StyleFlagsProperty()

Expand Down Expand Up @@ -245,6 +245,8 @@ class StylesBase(ABC):
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
content_align = AlignProperty()

_color_auto: bool = False

def __eq__(self, styles: object) -> bool:
"""Check that Styles contains the same rules."""
if not isinstance(styles, StylesBase):
Expand Down Expand Up @@ -438,6 +440,10 @@ def align_height(self, height: int, parent_height: int) -> int:
offset_y = parent_height - height
return offset_y

@property
def color_auto(self) -> bool:
return self._color_auto


@rich.repr.auto
@dataclass
Expand Down Expand Up @@ -684,7 +690,11 @@ def append_declaration(name: str, value: str) -> None:
assert self.layout is not None
append_declaration("layout", self.layout.name)

if has_rule("color"):
if self._color_auto:
append_declaration(
"color", f"auto {self.color.a * 100}%" if self.color.a < 1 else "auto"
)
elif has_rule("color"):
append_declaration("color", self.color.hex)
if has_rule("background"):
append_declaration("background", self.background.hex)
Expand Down
4 changes: 2 additions & 2 deletions src/textual/widget.py
Expand Up @@ -71,14 +71,14 @@ def cursor_line(self) -> int | None:
class Widget(DOMNode):

CSS = """
Widget{
Widget {
color: auto;
scrollbar-background: $panel-darken-2;
scrollbar-background-hover: $panel-darken-3;
scrollbar-color: $system;
scrollbar-color-active: $secondary-darken-1;
scrollbar-size-vertical: 2;
scrollbar-size-horizontal: 1;

}
"""

Expand Down
29 changes: 17 additions & 12 deletions tests/css/test_parse.py
Expand Up @@ -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)),
Copy link
Author

Choose a reason for hiding this comment

The 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};
}}
Expand Down