Separate parsing of scalar, number, duration

This commit is contained in:
Darren Burns
2022-01-19 14:56:03 +00:00
parent 644fdc7ed1
commit fd47ef491b
9 changed files with 197 additions and 50 deletions

View File

@@ -5,4 +5,4 @@ typecheck:
format:
black src
format-check:
black --check .
black --check src

View File

@@ -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"

View File

@@ -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.")
value, unit_name = match.groups()
value = float(value)
if unit_name == "ms":
duration_secs = value / 1000
if match:
value, unit_name = match.groups()
value = float(value)
if unit_name == "ms":
duration_secs = value / 1000
else:
duration_secs = value
else:
duration_secs = value
try:
duration_secs = float(duration)
except ValueError:
raise DurationParseError(
f"'{duration}' is not a valid duration."
) from ValueError
return duration_secs

View File

@@ -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):

View File

@@ -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:

View File

@@ -258,6 +258,7 @@ if __name__ == "__main__":
css = """#something {
text: on red;
transition: offset 5.51s in_out_cubic;
offset-x: 100%;
}
"""

View File

@@ -6,6 +6,7 @@ from typing import Iterable, NamedTuple, TYPE_CHECKING
import rich.repr
from textual.css.tokenizer import Token
from ..geometry import Offset

View File

@@ -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))

View File

@@ -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