mirror of
				https://github.com/Textualize/textual.git
				synced 2025-10-17 02:38:12 +03:00 
			
		
		
		
	Separate parsing of scalar, number, duration
This commit is contained in:
		
							
								
								
									
										2
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Makefile
									
									
									
									
									
								
							| @@ -5,4 +5,4 @@ typecheck: | ||||
| format: | ||||
| 	black src | ||||
| format-check: | ||||
| 	black --check . | ||||
| 	black --check src | ||||
|   | ||||
| @@ -17,7 +17,6 @@ classifiers = [ | ||||
|     "Programming Language :: Python :: 3.10", | ||||
| ] | ||||
|  | ||||
|  | ||||
| [tool.poetry.dependencies] | ||||
| python = "^3.7" | ||||
| rich = "^10.12.0" | ||||
| @@ -25,7 +24,6 @@ rich = "^10.12.0" | ||||
| typing-extensions = { version = "^3.10.0", python = "<3.8" } | ||||
|  | ||||
| [tool.poetry.dev-dependencies] | ||||
|  | ||||
| pytest = "^6.2.3" | ||||
| black = "^21.11b1" | ||||
| mypy = "^0.910" | ||||
| @@ -35,6 +33,9 @@ mkdocstrings = "^0.15.2" | ||||
| mkdocs-material = "^7.1.10" | ||||
| pre-commit = "^2.13.0" | ||||
|  | ||||
| [tool.black] | ||||
| includes = "src" | ||||
|  | ||||
| [build-system] | ||||
| requires = ["poetry-core>=1.0.0"] | ||||
| build-backend = "poetry.core.masonry.api" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import re | ||||
|  | ||||
| _match_duration = re.compile(r"^(-?\d+\.?\d*)(s|ms)?$").match | ||||
| _match_duration = re.compile(r"^(-?\d+\.?\d*)(s|ms)$").match | ||||
|  | ||||
|  | ||||
| class DurationError(Exception): | ||||
| @@ -14,19 +14,30 @@ class DurationParseError(DurationError): | ||||
| def _duration_as_seconds(duration: str) -> float: | ||||
|     """ | ||||
|     Args: | ||||
|         duration (str): A string of the form "2s" or "300ms", representing 2 seconds and 300 milliseconds respectively. | ||||
|  | ||||
|     Returns (float): The duration converted to seconds. | ||||
|         duration (str): A string of the form ``"2s"`` or ``"300ms"``, representing 2 seconds and | ||||
|             300 milliseconds respectively. If no unit is supplied, e.g. ``"2"``, then the duration is | ||||
|             assumed to be in seconds. | ||||
|     Raises: | ||||
|         DurationParseError: If the argument ``duration`` is not a valid duration string. | ||||
|     Returns: | ||||
|         float: The duration in seconds. | ||||
|  | ||||
|     """ | ||||
|     match = _match_duration(duration) | ||||
|     if not match: | ||||
|         raise DurationParseError(f"'{duration}' is not a valid duration.") | ||||
|  | ||||
|     if match: | ||||
|         value, unit_name = match.groups() | ||||
|         value = float(value) | ||||
|         if unit_name == "ms": | ||||
|             duration_secs = value / 1000 | ||||
|         else: | ||||
|             duration_secs = value | ||||
|     else: | ||||
|         try: | ||||
|             duration_secs = float(duration) | ||||
|         except ValueError: | ||||
|             raise DurationParseError( | ||||
|                 f"'{duration}' is not a valid duration." | ||||
|             ) from ValueError | ||||
|  | ||||
|     return duration_secs | ||||
|   | ||||
| @@ -55,7 +55,6 @@ class ScalarProperty: | ||||
|     def __set__( | ||||
|         self, obj: Styles, value: float | Scalar | str | None | ||||
|     ) -> float | Scalar | str | None: | ||||
|         new_value: Scalar | None = None | ||||
|         if value is None: | ||||
|             new_value = None | ||||
|         elif isinstance(value, float): | ||||
|   | ||||
| @@ -9,7 +9,6 @@ from rich.style import Style | ||||
| from .constants import VALID_BORDER, VALID_EDGE, VALID_DISPLAY, VALID_VISIBILITY | ||||
| from .errors import DeclarationError | ||||
| from ._error_tools import friendly_list | ||||
| from .. import log | ||||
| from .._duration import _duration_as_seconds | ||||
| from .._easing import EASING | ||||
| from ..geometry import Spacing, SpacingDimensions | ||||
| @@ -239,14 +238,21 @@ class StylesBuilder: | ||||
|         if not tokens: | ||||
|             return | ||||
|         if len(tokens) != 2: | ||||
|             self.error(name, tokens[0], "expected two numbers in declaration") | ||||
|             self.error( | ||||
|                 name, tokens[0], "expected two scalars or numbers in declaration" | ||||
|             ) | ||||
|         else: | ||||
|             token1, token2 = tokens | ||||
|  | ||||
|             if token1.name != "scalar": | ||||
|                 self.error(name, token1, f"expected a scalar; found {token1.value!r}") | ||||
|             if token2.name != "scalar": | ||||
|                 self.error(name, token2, f"expected a scalar; found {token1.value!r}") | ||||
|             if token1.name not in ("scalar", "number"): | ||||
|                 self.error( | ||||
|                     name, token1, f"expected a scalar or number; found {token1.value!r}" | ||||
|                 ) | ||||
|             if token2.name not in ("scalar", "number"): | ||||
|                 self.error( | ||||
|                     name, token2, f"expected a scalar or number; found {token2.value!r}" | ||||
|                 ) | ||||
|  | ||||
|             scalar_x = Scalar.parse(token1.value, Unit.WIDTH) | ||||
|             scalar_y = Scalar.parse(token2.value, Unit.HEIGHT) | ||||
|             self.styles._rule_offset = ScalarOffset(scalar_x, scalar_y) | ||||
| @@ -258,7 +264,7 @@ class StylesBuilder: | ||||
|             self.error(name, tokens[0], f"expected a single number") | ||||
|         else: | ||||
|             token = tokens[0] | ||||
|             if token.name != "scalar": | ||||
|             if token.name not in ("scalar", "number"): | ||||
|                 self.error(name, token, f"expected a scalar; found {token.value!r}") | ||||
|             x = Scalar.parse(token.value, Unit.WIDTH) | ||||
|             y = self.styles.offset.y | ||||
| @@ -271,7 +277,7 @@ class StylesBuilder: | ||||
|             self.error(name, tokens[0], f"expected a single number") | ||||
|         else: | ||||
|             token = tokens[0] | ||||
|             if token.name != "scalar": | ||||
|             if token.name not in ("scalar", "number"): | ||||
|                 self.error(name, token, f"expected a scalar; found {token.value!r}") | ||||
|             y = Scalar.parse(token.value, Unit.HEIGHT) | ||||
|             x = self.styles.offset.x | ||||
| @@ -406,6 +412,7 @@ class StylesBuilder: | ||||
|             if group: | ||||
|                 yield group | ||||
|  | ||||
|         valid_duration_token_types = ("duration", "number") | ||||
|         for tokens in make_groups(): | ||||
|             css_property = "" | ||||
|             duration = 1.0 | ||||
| @@ -416,12 +423,12 @@ class StylesBuilder: | ||||
|                 iter_tokens = iter(tokens) | ||||
|                 token = next(iter_tokens) | ||||
|                 if token.name != "token": | ||||
|                     self.error(name, token, f"expected property {token.name}") | ||||
|                 css_property = token.value | ||||
|                     self.error(name, token, "expected property") | ||||
|  | ||||
|                 css_property = token.value | ||||
|                 token = next(iter_tokens) | ||||
|                 if token.name != "duration": | ||||
|                     self.error(name, token, "expected duration") | ||||
|                 if token.name not in valid_duration_token_types: | ||||
|                     self.error(name, token, "expected duration or number") | ||||
|                 try: | ||||
|                     duration = _duration_as_seconds(token.value) | ||||
|                 except ScalarError as error: | ||||
| @@ -440,8 +447,8 @@ class StylesBuilder: | ||||
|                 easing = token.value | ||||
|  | ||||
|                 token = next(iter_tokens) | ||||
|                 if token.name != "duration": | ||||
|                     self.error(name, token, "expected duration") | ||||
|                 if token.name not in valid_duration_token_types: | ||||
|                     self.error(name, token, "expected duration or number") | ||||
|                 try: | ||||
|                     delay = _duration_as_seconds(token.value) | ||||
|                 except ScalarError as error: | ||||
|   | ||||
| @@ -258,6 +258,7 @@ if __name__ == "__main__": | ||||
|     css = """#something { | ||||
|     text: on red; | ||||
|     transition: offset 5.51s in_out_cubic; | ||||
|     offset-x: 100%; | ||||
| } | ||||
| """ | ||||
|  | ||||
|   | ||||
| @@ -6,6 +6,7 @@ from typing import Iterable, NamedTuple, TYPE_CHECKING | ||||
|  | ||||
| import rich.repr | ||||
|  | ||||
| from textual.css.tokenizer import Token | ||||
| from ..geometry import Offset | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -51,8 +51,9 @@ expect_declaration_content = Expect( | ||||
|     declaration_end=r"\n|;", | ||||
|     whitespace=r"\s+", | ||||
|     comment_start=r"\/\*", | ||||
|     duration=r"\-?\d+\.?\d*(?:ms|s)?", | ||||
|     scalar=r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)?", | ||||
|     scalar=r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)", | ||||
|     duration=r"\d+\.?\d*(?:ms|s)", | ||||
|     number=r"\-?\d+\.?\d*", | ||||
|     color=r"\#[0-9a-fA-F]{6}|color\([0-9]{1,3}\)|rgb\(\d{1,3}\,\s?\d{1,3}\,\s?\d{1,3}\)", | ||||
|     key_value=r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+", | ||||
|     token="[a-zA-Z_-]+", | ||||
| @@ -129,8 +130,9 @@ tokenize_declarations = DeclarationTokenizerState() | ||||
| if __name__ == "__main__": | ||||
|     css = """#something { | ||||
|         text: on red; | ||||
|         transition: offset 500ms in_out_cubic; | ||||
|         offset-x: 10; | ||||
|     } | ||||
|     """ | ||||
|     # transition: offset 500 in_out_cubic; | ||||
|     tokens = tokenize(css, __name__) | ||||
|     pprint.pp(list(tokens)) | ||||
|   | ||||
| @@ -1,12 +1,24 @@ | ||||
| import pytest | ||||
| from rich.color import Color, ColorType | ||||
| from rich.style import Style | ||||
|  | ||||
| from textual.css.stylesheet import Stylesheet | ||||
| from textual.css.scalar import Scalar, Unit | ||||
| from textual.css.stylesheet import Stylesheet, StylesheetParseError | ||||
| from textual.css.transition import Transition | ||||
|  | ||||
|  | ||||
| def test_parse_text(): | ||||
| def test_parse_text_foreground(): | ||||
|     css = """#some-widget { | ||||
|         text: green; | ||||
|     } | ||||
|     """ | ||||
|     stylesheet = Stylesheet() | ||||
|     stylesheet.parse(css) | ||||
|  | ||||
|     styles = stylesheet.rules[0].styles | ||||
|     assert styles.text_color == Color.parse("green") | ||||
|  | ||||
|  | ||||
| def test_parse_text_background(): | ||||
|     css = """#some-widget { | ||||
|         text: on red; | ||||
|     } | ||||
| @@ -14,31 +26,144 @@ def test_parse_text(): | ||||
|     stylesheet = Stylesheet() | ||||
|     stylesheet.parse(css) | ||||
|  | ||||
|     rule = stylesheet.rules[0].styles | ||||
|  | ||||
|     assert rule.text_style == Style() | ||||
|     assert rule.text_background == Color("red", type=ColorType.STANDARD, number=1) | ||||
|     styles = stylesheet.rules[0].styles | ||||
|     assert styles.text_background == Color("red", type=ColorType.STANDARD, number=1) | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize( | ||||
|     "duration, parsed_duration", | ||||
|     [["5.57s", 5.57], | ||||
|      ["0.5s", 0.5], | ||||
|      ["1200ms", 1.2], | ||||
|      ["0.5ms", 0.0005]], | ||||
|     "offset_x, parsed_x, offset_y, parsed_y", | ||||
|     [ | ||||
|         [ | ||||
|             "-5.5%", | ||||
|             Scalar(-5.5, Unit.PERCENT, Unit.WIDTH), | ||||
|             "-30%", | ||||
|             Scalar(-30, Unit.PERCENT, Unit.HEIGHT), | ||||
|         ], | ||||
|         [ | ||||
|             "5%", | ||||
|             Scalar(5, Unit.PERCENT, Unit.WIDTH), | ||||
|             "40%", | ||||
|             Scalar(40, Unit.PERCENT, Unit.HEIGHT), | ||||
|         ], | ||||
|         [ | ||||
|             "10", | ||||
|             Scalar(10, Unit.CELLS, Unit.WIDTH), | ||||
|             "40", | ||||
|             Scalar(40, Unit.CELLS, Unit.HEIGHT), | ||||
|         ], | ||||
|     ], | ||||
| ) | ||||
| def test_parse_transition(duration, parsed_duration): | ||||
| def test_parse_offset_composite_rule(offset_x, parsed_x, offset_y, parsed_y): | ||||
|     css = f"""#some-widget {{ | ||||
|         transition: offset {duration} in_out_cubic; | ||||
|         offset: {offset_x} {offset_y}; | ||||
|     }} | ||||
|     """ | ||||
|     stylesheet = Stylesheet() | ||||
|     stylesheet.parse(css) | ||||
|  | ||||
|     rule = stylesheet.rules[0].styles | ||||
|     styles = stylesheet.rules[0].styles | ||||
|  | ||||
|     assert len(stylesheet.rules) == 1 | ||||
|     assert len(stylesheet.rules[0].errors) == 0 | ||||
|     assert rule.transitions == { | ||||
|         "offset": Transition(duration=parsed_duration, easing="in_out_cubic", delay=0.0) | ||||
|     assert stylesheet.rules[0].errors == [] | ||||
|     assert styles.offset.x == parsed_x | ||||
|     assert styles.offset.y == parsed_y | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize( | ||||
|     "offset_x, parsed_x, offset_y, parsed_y", | ||||
|     [ | ||||
|         [ | ||||
|             "-5.5%", | ||||
|             Scalar(-5.5, Unit.PERCENT, Unit.WIDTH), | ||||
|             "-30%", | ||||
|             Scalar(-30, Unit.PERCENT, Unit.HEIGHT), | ||||
|         ], | ||||
|         [ | ||||
|             "5%", | ||||
|             Scalar(5, Unit.PERCENT, Unit.WIDTH), | ||||
|             "40%", | ||||
|             Scalar(40, Unit.PERCENT, Unit.HEIGHT), | ||||
|         ], | ||||
|         [ | ||||
|             "10", | ||||
|             Scalar(10, Unit.CELLS, Unit.WIDTH), | ||||
|             "40", | ||||
|             Scalar(40, Unit.CELLS, Unit.HEIGHT), | ||||
|         ], | ||||
|     ], | ||||
| ) | ||||
| def test_parse_offset_separate_rules(offset_x, parsed_x, offset_y, parsed_y): | ||||
|     css = f"""#some-widget {{ | ||||
|         offset-x: {offset_x}; | ||||
|         offset-y: {offset_y}; | ||||
|     }} | ||||
|     """ | ||||
|     stylesheet = Stylesheet() | ||||
|     stylesheet.parse(css) | ||||
|  | ||||
|     styles = stylesheet.rules[0].styles | ||||
|  | ||||
|     assert len(stylesheet.rules) == 1 | ||||
|     assert stylesheet.rules[0].errors == [] | ||||
|     assert styles.offset.x == parsed_x | ||||
|     assert styles.offset.y == parsed_y | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize( | ||||
|     "duration, parsed_duration", | ||||
|     [ | ||||
|         ["5.57s", 5.57], | ||||
|         ["0.5s", 0.5], | ||||
|         ["1200ms", 1.2], | ||||
|         ["0.5ms", 0.0005], | ||||
|         ["20", 20.0], | ||||
|         ["0.1", 0.1], | ||||
|     ], | ||||
| ) | ||||
| def test_parse_transition(duration, parsed_duration): | ||||
|     easing = "in_out_cubic" | ||||
|     transition_property = "offset" | ||||
|     css = f"""#some-widget {{ | ||||
|         transition: {transition_property} {duration} {easing} {duration}; | ||||
|     }} | ||||
|     """ | ||||
|     stylesheet = Stylesheet() | ||||
|     stylesheet.parse(css) | ||||
|  | ||||
|     styles = stylesheet.rules[0].styles | ||||
|  | ||||
|     assert len(stylesheet.rules) == 1 | ||||
|     assert stylesheet.rules[0].errors == [] | ||||
|     assert styles.transitions == { | ||||
|         "offset": Transition( | ||||
|             duration=parsed_duration, easing=easing, delay=parsed_duration | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_parse_transition_no_delay_specified(): | ||||
|     css = f"#some-widget {{ transition: offset-x 1 in_out_cubic; }}" | ||||
|     stylesheet = Stylesheet() | ||||
|     stylesheet.parse(css) | ||||
|  | ||||
|     styles = stylesheet.rules[0].styles | ||||
|  | ||||
|     assert stylesheet.rules[0].errors == [] | ||||
|     assert styles.transitions == { | ||||
|         "offset-x": Transition(duration=1, easing="in_out_cubic", delay=0) | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_parse_transition_unknown_easing_function(): | ||||
|     invalid_func_name = "invalid_easing_function" | ||||
|     css = f"#some-widget {{ transition: offset 1 {invalid_func_name} 1; }}" | ||||
|  | ||||
|     stylesheet = Stylesheet() | ||||
|     with pytest.raises(StylesheetParseError) as ex: | ||||
|         stylesheet.parse(css) | ||||
|  | ||||
|     stylesheet_errors = stylesheet.rules[0].errors | ||||
|  | ||||
|     assert len(stylesheet_errors) == 1 | ||||
|     assert stylesheet_errors[0][0].value == invalid_func_name | ||||
|     assert ex.value.errors is not None | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Darren Burns
					Darren Burns