diff --git a/sandbox/basic.css b/sandbox/basic.css index a2ced4868..f9bc0b96d 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -224,4 +224,4 @@ Success { .horizontal { layout: horizontal -} \ No newline at end of file +} diff --git a/src/textual/color.py b/src/textual/color.py index 0a2521db3..fd7fd0bd8 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -23,7 +23,7 @@ from rich.color import Color as RichColor from rich.style import Style from rich.text import Text - +from textual.suggestions import get_suggestion from ._color_constants import COLOR_NAME_TO_RGB from .geometry import clamp @@ -77,6 +77,17 @@ split_pairs4: Callable[[str], tuple[str, str, str, str]] = itemgetter( class ColorParseError(Exception): """A color failed to parse""" + def __init__(self, message: str, suggested_color: str | None = None): + """ + Creates a new ColorParseError + + Args: + message (str): the error message + suggested_color (str | None): a close color we can suggest. Defaults to None. + """ + super().__init__(message) + self.suggested_color = suggested_color + @rich.repr.auto class Color(NamedTuple): @@ -271,7 +282,14 @@ class Color(NamedTuple): return cls(*color_from_name) color_match = RE_COLOR.match(color_text) if color_match is None: - raise ColorParseError(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 + if not color_text.startswith("#") and not color_text.startswith("rgb"): + # 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: + error_message += f"; did you mean '{suggested_color}'?" + raise ColorParseError(error_message, suggested_color) ( rgb_hex_triple, rgb_hex_quad, diff --git a/src/textual/css/_help_renderables.py b/src/textual/css/_help_renderables.py index 3a9d2e5ab..184256687 100644 --- a/src/textual/css/_help_renderables.py +++ b/src/textual/css/_help_renderables.py @@ -70,13 +70,13 @@ class HelpText: Attributes: summary (str): A succinct summary of the issue. - bullets (Iterable[Bullet]): Bullet points which provide additional - context around the issue. These are rendered below the summary. + bullets (Iterable[Bullet] | None): Bullet points which provide additional + context around the issue. These are rendered below the summary. Defaults to None. """ - def __init__(self, summary: str, *, bullets: Iterable[Bullet]) -> None: + def __init__(self, summary: str, *, bullets: Iterable[Bullet] = None) -> None: self.summary = summary - self.bullets = bullets + self.bullets = bullets or [] def __rich_console__( self, console: Console, options: ConsoleOptions diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py index dc6cf9ff5..b3ed0f3ec 100644 --- a/src/textual/css/_help_text.py +++ b/src/textual/css/_help_text.py @@ -4,6 +4,7 @@ import sys from dataclasses import dataclass from typing import Iterable +from textual.color import ColorParseError from textual.css._help_renderables import Example, Bullet, HelpText from textual.css.constants import ( VALID_BORDER, @@ -144,13 +145,13 @@ def property_invalid_value_help_text( HelpText: Renderable for displaying the help text for this property """ property_name = _contextualize_property_name(property_name, context) - bullets = [] + summary = f"Invalid CSS property [i]{property_name}[/]" if suggested_property_name: suggested_property_name = _contextualize_property_name( suggested_property_name, context ) - bullets.append(Bullet(f'Did you mean "{suggested_property_name}"?')) - return HelpText(f"Invalid CSS property [i]{property_name}[/]", bullets=bullets) + summary += f'. Did you mean "{suggested_property_name}"?' + return HelpText(summary) def spacing_wrong_number_of_values_help_text( @@ -303,6 +304,8 @@ def string_enum_help_text( def color_property_help_text( property_name: str, context: StylingContext, + *, + error: Exception = None, ) -> HelpText: """Help text to show when the user supplies an invalid value for a color property. For example, an unparseable color string. @@ -310,13 +313,20 @@ def color_property_help_text( Args: property_name (str): The name of the property context (StylingContext | None): The context the property is being used in. + error (ColorParseError | None): The error that caused this help text to be displayed. Defaults to None. Returns: HelpText: Renderable for displaying the help text for this property """ property_name = _contextualize_property_name(property_name, context) + summary = f"Invalid value for the [i]{property_name}[/] property" + suggested_color = ( + error.suggested_color if error and isinstance(error, ColorParseError) else None + ) + if suggested_color: + summary += f'. Did you mean "{suggested_color}"?' return HelpText( - summary=f"Invalid value for the [i]{property_name}[/] property", + summary=summary, bullets=[ Bullet( f"The [i]{property_name}[/] property can only be set to a valid color" diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 19f402bb4..b7a161c71 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -782,10 +782,12 @@ class ColorProperty: elif isinstance(color, str): try: parsed_color = Color.parse(color) - except ColorParseError: + except ColorParseError as error: raise StyleValueError( f"Invalid color value '{color}'", - help_text=color_property_help_text(self.name, context="inline"), + help_text=color_property_help_text( + self.name, context="inline", error=error + ), ) if obj.set_rule(self.name, parsed_color): obj.refresh() diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 41ceea9ac..79935716e 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -572,9 +572,11 @@ class StylesBuilder: elif token.name in ("color", "token"): try: color = Color.parse(token.value) - except Exception: + except Exception as error: self.error( - name, token, color_property_help_text(name, context="css") + name, + token, + color_property_help_text(name, context="css", error=error), ) else: self.error(name, token, color_property_help_text(name, context="css")) diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index e9042675b..9ef433a29 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -90,14 +90,48 @@ def test_did_you_mean_for_css_property_names( _, help_text = err.value.errors.rules[0].errors[0] # type: Any, HelpText displayed_css_property_name = css_property_name.replace("_", "-") - assert ( - help_text.summary == f"Invalid CSS property [i]{displayed_css_property_name}[/]" + expected_summary = f"Invalid CSS property [i]{displayed_css_property_name}[/]" + if expected_property_name_suggestion: + expected_summary += f'. Did you mean "{expected_property_name_suggestion}"?' + assert help_text.summary == expected_summary + + +@pytest.mark.parametrize( + "css_property_name,css_property_value,expected_color_suggestion", + [ + ["color", "blu", "blue"], + ["background", "chartruse", "chartreuse"], + ["tint", "ansi_whi", "ansi_white"], + ["scrollbar-color", "transprnt", "transparent"], + ["color", "xkcd", None], + ], +) +def test_did_you_mean_for_color_names( + css_property_name: str, css_property_value: str, expected_color_suggestion +): + stylesheet = Stylesheet() + css = """ + * { + border: blue; + ${PROPERTY}: ${VALUE}; + } + """.replace( + "${PROPERTY}", css_property_name + ).replace( + "${VALUE}", css_property_value ) - expected_bullets_length = 1 if expected_property_name_suggestion else 0 - assert len(help_text.bullets) == expected_bullets_length - if expected_property_name_suggestion is not None: - expected_suggestion_message = ( - f'Did you mean "{expected_property_name_suggestion}"?' - ) - assert help_text.bullets[0].markup == expected_suggestion_message + stylesheet.add_source(css) + with pytest.raises(StylesheetParseError) as err: + stylesheet.parse() + + _, help_text = err.value.errors.rules[0].errors[0] # type: Any, HelpText + displayed_css_property_name = css_property_name.replace("_", "-") + expected_error_summary = ( + f"Invalid value for the [i]{displayed_css_property_name}[/] property" + ) + + if expected_color_suggestion is not None: + expected_error_summary += f'. Did you mean "{expected_color_suggestion}"?' + + assert help_text.summary == expected_error_summary