Merge pull request #279 from Textualize/opacity-rules

Opacity rules
This commit is contained in:
Will McGugan
2022-02-14 10:36:39 +00:00
committed by GitHub
9 changed files with 171 additions and 10 deletions

View File

@@ -18,6 +18,7 @@ class BasicApp(App):
self.bind("tab", "toggle_class('#sidebar', '-active')")
self.bind("a", "toggle_class('#header', '-visible')")
self.bind("c", "toggle_class('#content', '-content-visible')")
self.bind("d", "toggle_class('#footer', 'dim')")
def on_mount(self):
"""Build layout here."""

View File

@@ -52,7 +52,12 @@ Widget:hover {
}
#footer {
opacity: 1;
text: $text on $primary;
height: 3;
border-top: hkey $secondary;
}
#footer.dim {
opacity: 0.5;
}

View File

@@ -9,8 +9,7 @@ when setting and getting.
from __future__ import annotations
from typing import Iterable, NamedTuple, TYPE_CHECKING
from typing import Iterable, NamedTuple, TYPE_CHECKING, cast
import rich.repr
from rich.color import Color
@@ -28,7 +27,7 @@ from .scalar import (
ScalarParseError,
)
from .transition import Transition
from ..geometry import Spacing, SpacingDimensions
from ..geometry import Spacing, SpacingDimensions, clamp
if TYPE_CHECKING:
from ..layout import Layout
@@ -756,7 +755,7 @@ class TransitionsProperty:
def __get__(
self, obj: Styles, objtype: type[Styles] | None = None
) -> dict[str, Transition]:
"""Get a mapping of properties to the the transitions applied to them.
"""Get a mapping of properties to the transitions applied to them.
Args:
obj (Styles): The ``Styles`` object.
@@ -774,3 +773,51 @@ class TransitionsProperty:
obj.clear_rule("transitions")
else:
obj.set_rule("transitions", transitions.copy())
class FractionalProperty:
"""Property that can be set either as a float (e.g. 0.1) or a
string percentage (e.g. '10%'). Values will be clamped to the range (0, 1).
"""
def __init__(self, default: float = 1.0):
self.default = default
def __set_name__(self, owner: Styles, name: str) -> None:
self.name = name
def __get__(self, obj: Styles, type: type[Styles]) -> float:
"""Get the property value as a float between 0 and 1
Args:
obj (Styles): The ``Styles`` object.
objtype (type[Styles]): The ``Styles`` class.
Returns:
float: The value of the property (in the range (0, 1))
"""
return cast(float, obj.get_rule(self.name, self.default))
def __set__(self, obj: Styles, value: float | str | None) -> None:
"""Set the property value, clamping it between 0 and 1.
Args:
obj (Styles): The Styles object.
value (float|str|None): The value to set as a float between 0 and 1, or
as a percentage string such as '10%'.
"""
obj.refresh()
name = self.name
if value is None:
obj.clear_rule(name)
return
if isinstance(value, float):
float_value = value
elif isinstance(value, str) and value.endswith("%"):
float_value = float(Scalar.parse(value).value) / 100
else:
raise StyleTypeError(
f"{self.name} must be a str (e.g. '10%') or a float (e.g. 0.1)"
)
obj.set_rule(name, clamp(float_value, 0, 1))

View File

@@ -17,8 +17,20 @@ from .transition import Transition
from .types import Edge, Display, Visibility
from .._duration import _duration_as_seconds
from .._easing import EASING
from .._loop import loop_last
from ..geometry import Spacing, SpacingDimensions
from ..geometry import Spacing, SpacingDimensions, clamp
def _join_tokens(tokens: Iterable[Token], joiner: str = "") -> str:
"""Convert tokens into a string by joining their values
Args:
tokens (Iterable[Token]): Tokens to join
joiner (str): String to join on, defaults to ""
Returns:
str: The tokens, joined together to form a string.
"""
return joiner.join(token.value for token in tokens)
class StylesBuilder:
@@ -119,11 +131,46 @@ class StylesBuilder:
self.error(
name,
token,
f"invalid value for visibility (received {value!r}, expected {friendly_list(VALID_VISIBILITY)})",
f"property 'visibility' has invalid value {value!r}; expected {friendly_list(VALID_VISIBILITY)}",
)
else:
self.error(name, token, f"invalid token {value!r} in this context")
def process_opacity(self, name: str, tokens: list[Token], important: bool) -> None:
if not tokens:
return
token = tokens[0]
error = False
if len(tokens) != 1:
error = True
else:
token_name = token.name
value = token.value
if token_name == "scalar" and value.endswith("%"):
percentage = value[:-1]
try:
opacity = clamp(float(percentage) / 100, 0, 1)
self.styles.set_rule(name, opacity)
except ValueError:
error = True
elif token_name == "number":
try:
opacity = clamp(float(value), 0, 1)
self.styles.set_rule(name, opacity)
except ValueError:
error = True
else:
error = True
if error:
self.error(
name,
token,
f"property 'opacity' has invalid value {_join_tokens(tokens)!r}; "
f"expected a percentage or float between 0 and 1; "
f"example valid values: '0.4', '40%'",
)
def _process_space(self, name: str, tokens: list[Token]) -> None:
space: list[int] = []
append = space.append
@@ -295,7 +342,7 @@ class StylesBuilder:
)
def process_text(self, name: str, tokens: list[Token], important: bool) -> None:
style_definition = " ".join(token.value for token in tokens)
style_definition = _join_tokens(tokens, joiner=" ")
# If every token in the value is a referenced by the same variable,
# we can display the variable name before the style definition.

View File

@@ -28,6 +28,7 @@ from ._style_properties import (
StyleFlagsProperty,
StyleProperty,
TransitionsProperty,
FractionalProperty,
)
from .constants import VALID_DISPLAY, VALID_VISIBILITY
from .scalar import Scalar, ScalarOffset, Unit
@@ -62,6 +63,8 @@ class RulesMap(TypedDict, total=False):
text_background: Color
text_style: Style
opacity: float
padding: Spacing
margin: Spacing
offset: ScalarOffset
@@ -121,6 +124,8 @@ class StylesBase(ABC):
text_background = ColorProperty()
text_style = StyleFlagsProperty()
opacity = FractionalProperty()
padding = SpacingProperty()
margin = SpacingProperty()
offset = OffsetProperty()

View File

@@ -4,7 +4,6 @@ import re
from typing import NamedTuple
import rich.repr
from rich.cells import cell_len
class EOFError(Exception):

View File

@@ -32,7 +32,7 @@ from .geometry import Size, Spacing
from .message import Message
from .messages import Layout, Update
from .reactive import watch
from .renderables.opacity import Opacity
if TYPE_CHECKING:
from .view import View
@@ -174,6 +174,9 @@ class Widget(DOMNode):
style=renderable_text_style,
)
if styles.opacity:
renderable = Opacity(renderable, opacity=styles.opacity)
return renderable
@property

View File

@@ -314,3 +314,29 @@ class TestParseTransition:
assert len(stylesheet_errors) == 1
assert stylesheet_errors[0][0].value == invalid_func_name
assert ex.value.errors is not None
class TestParseOpacity:
@pytest.mark.parametrize("css_value, styles_value", [
["-0.2", 0.0],
["0.4", 0.4],
["1.3", 1.0],
["-20%", 0.0],
["25%", 0.25],
["128%", 1.0],
])
def test_opacity_to_styles(self, css_value, styles_value):
css = f"#some-widget {{ opacity: {css_value} }}"
stylesheet = Stylesheet()
stylesheet.parse(css)
assert stylesheet.rules[0].styles.opacity == styles_value
assert not stylesheet.rules[0].errors
def test_opacity_invalid_value(self):
css = "#some-widget { opacity: 123x }"
stylesheet = Stylesheet()
with pytest.raises(StylesheetParseError):
stylesheet.parse(css)
assert stylesheet.rules[0].errors

View File

@@ -1,7 +1,10 @@
import pytest
from rich.color import Color
from rich.style import Style
from textual.css.errors import StyleTypeError
from textual.css.styles import Styles, RenderStyles
from textual.dom import DOMNode
def test_styles_reset():
@@ -126,3 +129,28 @@ def test_render_styles_border():
("", Color.default()),
("rounded", Color.parse("green")),
)
def test_get_opacity_default():
styles = RenderStyles(DOMNode(), Styles(), Styles())
assert styles.opacity == 1.
@pytest.mark.parametrize("set_value, expected", [
[0.2, 0.2],
[-0.4, 0.0],
[5.8, 1.0],
["25%", 0.25],
["-10%", 0.0],
["120%", 1.0],
])
def test_opacity_set_then_get(set_value, expected):
styles = RenderStyles(DOMNode(), Styles(), Styles())
styles.opacity = set_value
assert styles.opacity == expected
def test_opacity_set_invalid_type_error():
styles = RenderStyles(DOMNode(), Styles(), Styles())
with pytest.raises(StyleTypeError):
styles.opacity = "invalid value"