diff --git a/sandbox/uber.css b/sandbox/uber.css index cc063fab8..86b89d451 100644 --- a/sandbox/uber.css +++ b/sandbox/uber.css @@ -10,5 +10,5 @@ height: 8; min-width: 80; background: dark_blue; - margin-bottom: 4; + padding: 1 2; } diff --git a/sandbox/uber.py b/sandbox/uber.py index 00582f0cf..09edc0bc3 100644 --- a/sandbox/uber.py +++ b/sandbox/uber.py @@ -87,8 +87,8 @@ class BasicApp(App): def action_increase_margin(self): old_margin = self.focused.styles.margin - new_margin = old_margin + Spacing.all(1) - self.focused.styles.margin = new_margin + # new_margin = old_margin + (1,1,1) + self.focused.styles.padding = (1, 1, 1) BasicApp.run(css_file="uber.css", log="textual.log", log_verbosity=1) diff --git a/src/textual/css/_error_tools.py b/src/textual/css/_error_tools.py index 48d69c173..68ffc23e2 100644 --- a/src/textual/css/_error_tools.py +++ b/src/textual/css/_error_tools.py @@ -16,7 +16,7 @@ def friendly_list(words: Iterable[str], joiner: str = "or") -> str: Returns: str: List as prose. """ - words = [repr(word) for word in sorted(words, key=str.lower)] + words = [repr(word) for word in sorted(words, key=str.lower) if word] if len(words) == 1: return words[0] elif len(words) == 2: diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py new file mode 100644 index 000000000..b1dc29381 --- /dev/null +++ b/src/textual/css/_help_text.py @@ -0,0 +1,31 @@ +from textual.css._error_tools import friendly_list +from textual.css.scalar import SYMBOL_UNIT + + +def _inline_name(property_name: str) -> str: + return property_name.replace("-", "_") + + +def spacing_help_text(property_name: str, num_values_supplied: int) -> str: + return f"""\ +• You supplied {num_values_supplied} values for the '{property_name}' property +• Spacing properties like 'margin' and 'padding' require either 1, 2 or 4 integer values +• In Textual CSS, supply 1, 2 or 4 values separated by a space + [dim]e.g. [/][i]{property_name}: 1 2 3 4;[/] +• In Python, you can set it to a tuple to assign spacing to each edge + [dim]e.g. [/][i]widget.styles.{_inline_name(property_name)} = (1, 2, 3, 4)[/] +• Or to an integer to assign to all edges at once + [dim]e.g. [/][i]widget.styles.{_inline_name(property_name)} = 2[/]""" + + +def scalar_help_text(property_name: str) -> str: + return f"""\ +• Invalid value for the '{property_name}' property +• Scalar properties like '{property_name}' require numerical values and an optional unit +• Valid units are {friendly_list(SYMBOL_UNIT)} +• Here's an example of how you'd set a scalar property in Textual CSS + [dim]e.g. [/][i]{property_name}: 50%;[/] +• In Python, you can assign a string, int or Scalar object itself + [dim]e.g. [/][i]widget.styles.{_inline_name(property_name)} = "50%"[/] + [dim]e.g. [/][i]widget.styles.{_inline_name(property_name)} = 10[/] + [dim]e.g. [/][i]widget.styles.{_inline_name(property_name)} = Scalar(...)[/]""" diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index c059d8cc8..b6265daa3 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -9,11 +9,13 @@ when setting and getting. from __future__ import annotations +import inspect from typing import Iterable, NamedTuple, TYPE_CHECKING, cast import rich.repr from rich.style import Style +from ._help_text import spacing_help_text, scalar_help_text from ..color import Color, ColorPair from ._error_tools import friendly_list from .constants import NULL_SPACING @@ -98,7 +100,10 @@ class ScalarProperty: try: new_value = Scalar.parse(value) except ScalarParseError: - raise StyleValueError("unable to parse scalar from {value!r}") + raise StyleValueError( + "unable to parse scalar from {value!r}", + help_text=scalar_help_text(property_name=self.name), + ) else: raise StyleValueError("expected float, int, Scalar, or None") if new_value is not None and new_value.unit not in self.units: @@ -368,7 +373,16 @@ class SpacingProperty: if obj.clear_rule(self.name): obj.refresh(layout=True) else: - if obj.set_rule(self.name, Spacing.unpack(spacing)): + try: + unpacked_spacing = Spacing.unpack(spacing) + except ValueError as error: + raise StyleValueError( + str(error), + help_text=spacing_help_text( + property_name=self.name, num_values_supplied=len(spacing) + ), + ) + if obj.set_rule(self.name, unpacked_spacing): obj.refresh(layout=True) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index a78344c59..d44e882d5 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -5,6 +5,7 @@ from typing import cast, Iterable, NoReturn import rich.repr from ._error_tools import friendly_list +from ._help_text import spacing_help_text, scalar_help_text from .constants import ( VALID_BORDER, VALID_BOX_SIZING, @@ -15,7 +16,7 @@ from .constants import ( ) from .errors import DeclarationError from .model import Declaration -from .scalar import Scalar, ScalarOffset, Unit, ScalarError +from .scalar import Scalar, ScalarOffset, Unit, ScalarError, ScalarParseError from .styles import DockGroup, Styles from .tokenize import Token from .transition import Transition @@ -160,12 +161,20 @@ class StylesBuilder: self.error(name, token, f"invalid token {value!r} in this context") def _process_scalar(self, name: str, tokens: list[Token]) -> None: + def scalar_error(): + self.error(name, tokens[0], scalar_help_text(property_name=name)) + if not tokens: return if len(tokens) == 1: - self.styles._rules[name.replace("-", "_")] = Scalar.parse(tokens[0].value) + try: + self.styles._rules[name.replace("-", "_")] = Scalar.parse( + tokens[0].value + ) + except ScalarParseError: + scalar_error() else: - self.error(name, tokens[0], "a single scalar is expected") + scalar_error() def process_box_sizing(self, name: str, tokens: list[Token]) -> None: for token in tokens: @@ -287,7 +296,7 @@ class StylesBuilder: self.error( name, tokens[0], - f"1, 2, or 4 values expected; received {len(space)} values", + spacing_help_text(name, num_values_supplied=len(space)), ) self.styles._rules[name] = Spacing.unpack(cast(SpacingDimensions, tuple(space))) diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index d738a8341..17abc9525 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -31,4 +31,4 @@ VALID_BOX_SIZING: Final = {"border-box", "content-box"} VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"} -NULL_SPACING: Final = Spacing(0, 0, 0, 0) +NULL_SPACING: Final = Spacing.all(0) diff --git a/src/textual/css/errors.py b/src/textual/css/errors.py index d4196db4e..45be6062d 100644 --- a/src/textual/css/errors.py +++ b/src/textual/css/errors.py @@ -1,3 +1,9 @@ +from __future__ import annotations + +from rich.console import ConsoleOptions, Console +from rich.padding import Padding +from rich.traceback import Traceback + from .tokenize import Token @@ -18,7 +24,13 @@ class StyleTypeError(TypeError): class StyleValueError(ValueError): - pass + def __init__(self, *args, help_text: str | None = None): + super().__init__(*args) + self.help_text = help_text + + def __rich_console__(self, console: Console, options: ConsoleOptions): + yield Traceback.from_exception(type(self), self, self.__traceback__) + yield Padding(self.help_text, pad=(1, 0, 0, 1)) class StylesheetError(Exception): diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 8ff1382e9..507289ec0 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -10,6 +10,7 @@ from typing import cast, Iterable import rich.repr from rich.console import Group, RenderableType from rich.highlighter import ReprHighlighter +from rich.markup import render from rich.padding import Padding from rich.panel import Panel from rich.syntax import Syntax @@ -88,13 +89,7 @@ class StylesheetErrors: ) append(self._get_snippet(token.code, line_no)) - final_message = "" - for is_last, message_part in loop_last(message.split(";")): - end = "" if is_last else "\n" - final_message += f"• {message_part.strip()};{end}" - - append(Padding(highlighter(Text(final_message, "red")), pad=(0, 1))) - append("") + append(Padding(highlighter(render(message)), pad=(0, 1))) return Group(*errors) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 1a8ab67bc..be617d57f 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -640,7 +640,9 @@ class Spacing(NamedTuple): if pad_len == 4: top, right, bottom, left = cast(Tuple[int, int, int, int], pad) return cls(top, right, bottom, left) - raise ValueError(f"1, 2 or 4 integers required for spacing; {pad_len} given") + raise ValueError( + f"1, 2 or 4 integers required for spacing properties; {pad_len} given" + ) @classmethod def vertical(cls, amount: int) -> Spacing: