mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -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."""
|
||||
|
||||
@@ -52,7 +52,12 @@ Widget:hover {
|
||||
}
|
||||
|
||||
#footer {
|
||||
opacity: 1;
|
||||
text: $text on $primary;
|
||||
height: 3;
|
||||
border-top: hkey $secondary;
|
||||
}
|
||||
|
||||
#footer.dim {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -4,7 +4,6 @@ import re
|
||||
from typing import NamedTuple
|
||||
|
||||
import rich.repr
|
||||
from rich.cells import cell_len
|
||||
|
||||
|
||||
class EOFError(Exception):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user