From e37036c81601470725275c9e28db5d3abc8be91d Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Tue, 10 May 2022 11:09:29 +0100 Subject: [PATCH] [css] Add a "Did you mean" suggestion when the value of a color is wrong but we can find a close one --- sandbox/basic.css | 2 +- src/textual/color.py | 22 ++++++++++++-- src/textual/css/_help_text.py | 12 ++++++++ src/textual/css/_style_properties.py | 8 +++-- src/textual/css/_styles_builder.py | 6 ++-- tests/css/test_stylesheet.py | 44 ++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 8 deletions(-) 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..97c1adeca 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_text.py b/src/textual/css/_help_text.py index dc6cf9ff5..c64ab3345 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, @@ -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,14 +313,23 @@ 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) + suggested_color = ( + error.suggested_color if error and isinstance(error, ColorParseError) else None + ) return HelpText( summary=f"Invalid value for the [i]{property_name}[/] property", bullets=[ + *( + [Bullet(f'Did you mean "{suggested_color}"?')] + if suggested_color + else [] + ), 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..f8fc13e42 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -782,11 +782,13 @@ 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 + ), + ) from error if obj.set_rule(self.name, parsed_color): obj.refresh() else: 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..8c5c93e65 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -101,3 +101,47 @@ def test_did_you_mean_for_css_property_names( f'Did you mean "{expected_property_name_suggestion}"?' ) assert help_text.bullets[0].markup == expected_suggestion_message + + +@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 + ) + + 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("_", "-") + assert ( + help_text.summary + == f"Invalid value for the [i]{displayed_css_property_name}[/] property" + ) + + first_bullet = help_text.bullets[0] + if expected_color_suggestion is not None: + expected_suggestion_message = f'Did you mean "{expected_color_suggestion}"?' + assert first_bullet.markup == expected_suggestion_message + else: + assert "Did you mean" not in first_bullet.markup