Merge pull request #486 from Textualize/css-add-did-you-mean-for-css-color-names

[css] Add a "Did you mean" suggestion when the value of a color is wrong but we can find a close one
This commit is contained in:
Will McGugan
2022-05-11 14:41:17 +01:00
committed by GitHub
7 changed files with 90 additions and 24 deletions

View File

@@ -224,4 +224,4 @@ Success {
.horizontal { .horizontal {
layout: horizontal layout: horizontal
} }

View File

@@ -23,7 +23,7 @@ from rich.color import Color as RichColor
from rich.style import Style from rich.style import Style
from rich.text import Text from rich.text import Text
from textual.suggestions import get_suggestion
from ._color_constants import COLOR_NAME_TO_RGB from ._color_constants import COLOR_NAME_TO_RGB
from .geometry import clamp from .geometry import clamp
@@ -77,6 +77,17 @@ split_pairs4: Callable[[str], tuple[str, str, str, str]] = itemgetter(
class ColorParseError(Exception): class ColorParseError(Exception):
"""A color failed to parse""" """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 @rich.repr.auto
class Color(NamedTuple): class Color(NamedTuple):
@@ -271,7 +282,14 @@ class Color(NamedTuple):
return cls(*color_from_name) return cls(*color_from_name)
color_match = RE_COLOR.match(color_text) color_match = RE_COLOR.match(color_text)
if color_match is None: 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_triple,
rgb_hex_quad, rgb_hex_quad,

View File

@@ -70,13 +70,13 @@ class HelpText:
Attributes: Attributes:
summary (str): A succinct summary of the issue. summary (str): A succinct summary of the issue.
bullets (Iterable[Bullet]): Bullet points which provide additional bullets (Iterable[Bullet] | None): Bullet points which provide additional
context around the issue. These are rendered below the summary. 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.summary = summary
self.bullets = bullets self.bullets = bullets or []
def __rich_console__( def __rich_console__(
self, console: Console, options: ConsoleOptions self, console: Console, options: ConsoleOptions

View File

@@ -4,6 +4,7 @@ import sys
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable from typing import Iterable
from textual.color import ColorParseError
from textual.css._help_renderables import Example, Bullet, HelpText from textual.css._help_renderables import Example, Bullet, HelpText
from textual.css.constants import ( from textual.css.constants import (
VALID_BORDER, VALID_BORDER,
@@ -144,13 +145,13 @@ def property_invalid_value_help_text(
HelpText: Renderable for displaying the help text for this property HelpText: Renderable for displaying the help text for this property
""" """
property_name = _contextualize_property_name(property_name, context) property_name = _contextualize_property_name(property_name, context)
bullets = [] summary = f"Invalid CSS property [i]{property_name}[/]"
if suggested_property_name: if suggested_property_name:
suggested_property_name = _contextualize_property_name( suggested_property_name = _contextualize_property_name(
suggested_property_name, context suggested_property_name, context
) )
bullets.append(Bullet(f'Did you mean "{suggested_property_name}"?')) summary += f'. Did you mean "{suggested_property_name}"?'
return HelpText(f"Invalid CSS property [i]{property_name}[/]", bullets=bullets) return HelpText(summary)
def spacing_wrong_number_of_values_help_text( def spacing_wrong_number_of_values_help_text(
@@ -303,6 +304,8 @@ def string_enum_help_text(
def color_property_help_text( def color_property_help_text(
property_name: str, property_name: str,
context: StylingContext, context: StylingContext,
*,
error: Exception = None,
) -> HelpText: ) -> HelpText:
"""Help text to show when the user supplies an invalid value for a color """Help text to show when the user supplies an invalid value for a color
property. For example, an unparseable color string. property. For example, an unparseable color string.
@@ -310,13 +313,20 @@ def color_property_help_text(
Args: Args:
property_name (str): The name of the property property_name (str): The name of the property
context (StylingContext | None): The context the property is being used in. 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: Returns:
HelpText: Renderable for displaying the help text for this property HelpText: Renderable for displaying the help text for this property
""" """
property_name = _contextualize_property_name(property_name, context) 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( return HelpText(
summary=f"Invalid value for the [i]{property_name}[/] property", summary=summary,
bullets=[ bullets=[
Bullet( Bullet(
f"The [i]{property_name}[/] property can only be set to a valid color" f"The [i]{property_name}[/] property can only be set to a valid color"

View File

@@ -782,10 +782,12 @@ class ColorProperty:
elif isinstance(color, str): elif isinstance(color, str):
try: try:
parsed_color = Color.parse(color) parsed_color = Color.parse(color)
except ColorParseError: except ColorParseError as error:
raise StyleValueError( raise StyleValueError(
f"Invalid color value '{color}'", 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): if obj.set_rule(self.name, parsed_color):
obj.refresh() obj.refresh()

View File

@@ -572,9 +572,11 @@ class StylesBuilder:
elif token.name in ("color", "token"): elif token.name in ("color", "token"):
try: try:
color = Color.parse(token.value) color = Color.parse(token.value)
except Exception: except Exception as error:
self.error( self.error(
name, token, color_property_help_text(name, context="css") name,
token,
color_property_help_text(name, context="css", error=error),
) )
else: else:
self.error(name, token, color_property_help_text(name, context="css")) self.error(name, token, color_property_help_text(name, context="css"))

View File

@@ -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 _, help_text = err.value.errors.rules[0].errors[0] # type: Any, HelpText
displayed_css_property_name = css_property_name.replace("_", "-") displayed_css_property_name = css_property_name.replace("_", "-")
assert ( expected_summary = f"Invalid CSS property [i]{displayed_css_property_name}[/]"
help_text.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 stylesheet.add_source(css)
assert len(help_text.bullets) == expected_bullets_length with pytest.raises(StylesheetParseError) as err:
if expected_property_name_suggestion is not None: stylesheet.parse()
expected_suggestion_message = (
f'Did you mean "{expected_property_name_suggestion}"?' _, help_text = err.value.errors.rules[0].errors[0] # type: Any, HelpText
) displayed_css_property_name = css_property_name.replace("_", "-")
assert help_text.bullets[0].markup == expected_suggestion_message 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