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