color animation

This commit is contained in:
Will McGugan
2022-03-30 15:04:36 +01:00
parent 736a56182c
commit 5508ece2e3
15 changed files with 1015 additions and 324 deletions

View File

@@ -5,15 +5,12 @@ $primary: #20639b;
App > Screen { App > Screen {
layout: dock; layout: dock;
docks: side=left/1; docks: side=left/1;
text: on $primary; background: $primary;
}
Widget:hover {
outline: solid green;
} }
#sidebar { #sidebar {
text: #09312e on #3caea3; color: #09312e;
background: #3caea3;
dock: side; dock: side;
width: 30; width: 30;
offset-x: -100%; offset-x: -100%;
@@ -27,17 +24,21 @@ Widget:hover {
} }
#header { #header {
text: white on #173f5f; color: white;
background: #173f5f;
height: 3; height: 3;
border: hkey; border: hkey white;
} }
#content { #content {
text: white on $primary; color: white;
background: $primary;
border-bottom: hkey #0f2b41; border-bottom: hkey #0f2b41;
} }
#footer { #footer {
text: #3a3009 on #f6d55c; color: #3a3009;
background: #f6d55c;
height: 3; height: 3;
} }

View File

@@ -1,7 +1,7 @@
#uber1 { #uber1 {
layout: vertical; layout: vertical;
text-background: dark_green; background: dark_green;
overflow: hidden auto; overflow: hidden auto;
border: heavy white; border: heavy white;
} }
@@ -9,5 +9,5 @@
.list-item { .list-item {
height: 8; height: 8;
min-width: 80; min-width: 80;
text-background: dark_blue; background: dark_blue;
} }

View File

@@ -186,7 +186,7 @@ class Animator:
easing_function = EASING[easing] if isinstance(easing, str) else easing easing_function = EASING[easing] if isinstance(easing, str) else easing
animation: Animation animation: Animation | None = None
if hasattr(obj, "__textual_animation__"): if hasattr(obj, "__textual_animation__"):
animation = getattr(obj, "__textual_animation__")( animation = getattr(obj, "__textual_animation__")(
attribute, attribute,
@@ -196,7 +196,7 @@ class Animator:
speed=speed, speed=speed,
easing=easing_function, easing=easing_function,
) )
else: if animation is None:
start_value = getattr(obj, attribute) start_value = getattr(obj, attribute)
if start_value == value: if start_value == value:

View File

@@ -720,4 +720,4 @@ class App(DOMNode):
self.screen.query(selector).toggle_class(class_name) self.screen.query(selector).toggle_class(class_name)
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None: async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
self.stylesheet.update(self) self.stylesheet.update(self, animate=True)

View File

@@ -4,11 +4,14 @@ from functools import lru_cache
import re import re
from typing import NamedTuple from typing import NamedTuple
from .geometry import clamp
import rich.repr import rich.repr
from rich.color import Color as RichColor from rich.color import Color as RichColor
from rich.style import Style from rich.style import Style
from . import log
from .geometry import clamp
ANSI_COLOR_NAMES = { ANSI_COLOR_NAMES = {
"black": 0, "black": 0,
"red": 1, "red": 1,
@@ -534,16 +537,29 @@ class Color(NamedTuple):
@classmethod @classmethod
def from_rich_color(cls, rich_color: RichColor) -> Color: def from_rich_color(cls, rich_color: RichColor) -> Color:
"""Create color from Rich's color class."""
r, g, b = rich_color.get_truecolor() r, g, b = rich_color.get_truecolor()
return cls(r, g, b) return cls(r, g, b)
@property @property
def rich_color(self) -> RichColor: def rich_color(self) -> RichColor:
"""This color encoded in Rich's Color class."""
r, g, b, _a = self r, g, b, _a = self
return RichColor.from_rgb(r, g, b) return RichColor.from_rgb(r, g, b)
@property
def is_transparent(self) -> bool:
"""Check if the color is transparent."""
return self.a == 0
@property @property
def hex(self) -> str: def hex(self) -> str:
"""The color in CSS hex form, with 6 digits for RGB, and 8 digits for RGBA.
Returns:
str: A CSS hex-style color, e.g. "#46b3de" or "#3342457f"
"""
r, g, b, a = self r, g, b, a = self
return ( return (
f"#{r:02X}{g:02X}{b:02X}" f"#{r:02X}{g:02X}{b:02X}"
@@ -553,6 +569,12 @@ class Color(NamedTuple):
@property @property
def css(self) -> str: def css(self) -> str:
"""The color in CSS rgb or rgba form.
Returns:
str: A CSS color, e.g. "rgb(10,20,30)" or "(rgb(50,70,80,0.5)"
"""
r, g, b, a = self r, g, b, a = self
return f"rgb({r},{g},{b})" if a == 1 else f"rgba({r},{g},{b},{a})" return f"rgb({r},{g},{b})" if a == 1 else f"rgba({r},{g},{b},{a})"
@@ -563,6 +585,25 @@ class Color(NamedTuple):
yield b yield b
yield "a", a yield "a", a
def blend(self, destination: Color, factor: float) -> Color:
"""Generate a new color between two colors.
Args:
destination (Color): Another color.
factor (float): A blend factor, 0 -> 1
Returns:
Color: A new color.
"""
r1, g1, b1, a1 = self
r2, g2, b2, a2 = destination
return Color(
int(r1 + (r2 - r1) * factor),
int(g1 + (g2 - g1) * factor),
int(b1 + (b2 - b1) * factor),
a1 + (a2 - a1) * factor,
)
@classmethod @classmethod
@lru_cache(maxsize=1024 * 4) @lru_cache(maxsize=1024 * 4)
def parse(cls, color_text: str) -> Color: def parse(cls, color_text: str) -> Color:
@@ -587,7 +628,10 @@ class Color(NamedTuple):
if rgb_hex is not None: if rgb_hex is not None:
color = cls( color = cls(
int(rgb_hex[0:2], 16), int(rgb_hex[2:4], 16), int(rgb_hex[4:6], 16), 1 int(rgb_hex[0:2], 16),
int(rgb_hex[2:4], 16),
int(rgb_hex[4:6], 16),
1.0,
) )
elif rgba_hex is not None: elif rgba_hex is not None:
color = cls( color = cls(

View File

@@ -15,7 +15,7 @@ import rich.repr
from rich.style import Style from rich.style import Style
from .. import log from .. import log
from ..color import Color from ..color import Color, ColorPair
from ._error_tools import friendly_list from ._error_tools import friendly_list
from .constants import NULL_SPACING from .constants import NULL_SPACING
from .errors import StyleTypeError, StyleValueError from .errors import StyleTypeError, StyleValueError
@@ -316,46 +316,9 @@ class StyleProperty:
Returns: Returns:
A ``Style`` object. A ``Style`` object.
""" """
has_rule = obj.has_rule style = ColorPair(obj.color, obj.background).style
style = Style.from_color(
obj.text_color.rich_color if has_rule("text_color") else None,
obj.text_background.rich_color if has_rule("text_background") else None,
)
if has_rule("text_style"):
style += obj.text_style
return style return style
def __set__(self, obj: StylesBase, style: Style | str | None):
"""Set the Style
Args:
obj (Styles): The ``Styles`` object.
style (Style | str, optional): You can supply the ``Style`` directly, or a
string (e.g. ``"blue on #f0f0f0"``).
Raises:
StyleSyntaxError: When the supplied style string has invalid syntax.
"""
obj.refresh()
if style is None:
clear_rule = obj.clear_rule
clear_rule("text_color")
clear_rule("text_background")
clear_rule("text_style")
else:
if isinstance(style, str):
style = Style.parse(style)
if style.color is not None:
obj.text_color = style.color
if style.bgcolor is not None:
obj.text_background = style.bgcolor
if style.without_color:
obj.text_style = str(style.without_color)
class SpacingProperty: class SpacingProperty:
"""Descriptor for getting and setting spacing properties (e.g. padding and margin).""" """Descriptor for getting and setting spacing properties (e.g. padding and margin)."""
@@ -660,9 +623,7 @@ class NameListProperty:
) -> tuple[str, ...]: ) -> tuple[str, ...]:
return obj.get_rule(self.name, ()) return obj.get_rule(self.name, ())
def __set__( def __set__(self, obj: StylesBase, names: str | tuple[str] | None = None):
self, obj: StylesBase, names: str | tuple[str] | None = None
) -> str | tuple[str] | None:
if names is None: if names is None:
if obj.clear_rule(self.name): if obj.clear_rule(self.name):
@@ -687,7 +648,7 @@ class ColorProperty:
self.name = name self.name = name
def __get__(self, obj: StylesBase, objtype: type[Styles] | None = None) -> Color: def __get__(self, obj: StylesBase, objtype: type[Styles] | None = None) -> Color:
"""Get the ``Color``, or ``Color.default()`` if no color is set. """Get a ``Color``.
Args: Args:
obj (Styles): The ``Styles`` object. obj (Styles): The ``Styles`` object.
@@ -696,7 +657,7 @@ class ColorProperty:
Returns: Returns:
Color: The Color Color: The Color
""" """
return obj.get_rule(self.name) or self._default_color return obj.get_rule(self.name, self._default_color)
def __set__(self, obj: StylesBase, color: Color | str | None): def __set__(self, obj: StylesBase, color: Color | str | None):
"""Set the Color """Set the Color

View File

@@ -459,36 +459,34 @@ class StylesBuilder:
f"invalid value for layout (received {value!r}, expected {friendly_list(LAYOUT_MAP.keys())})", f"invalid value for layout (received {value!r}, expected {friendly_list(LAYOUT_MAP.keys())})",
) )
def process_text(self, name: str, tokens: list[Token], important: bool) -> None: # def process_text(self, name: str, tokens: list[Token], important: bool) -> None:
style_definition = _join_tokens(tokens, joiner=" ") # style_definition = _join_tokens(tokens, joiner=" ")
# If every token in the value is a referenced by the same variable, # # If every token in the value is a referenced by the same variable,
# we can display the variable name before the style definition. # # we can display the variable name before the style definition.
# TODO: Factor this out to apply it to other properties too. # # TODO: Factor this out to apply it to other properties too.
unique_references = {t.referenced_by for t in tokens if t.referenced_by} # unique_references = {t.referenced_by for t in tokens if t.referenced_by}
if tokens and tokens[0].referenced_by and len(unique_references) == 1: # if tokens and tokens[0].referenced_by and len(unique_references) == 1:
variable_prefix = f"${tokens[0].referenced_by.name}=" # variable_prefix = f"${tokens[0].referenced_by.name}="
else: # else:
variable_prefix = "" # variable_prefix = ""
try: # try:
style = Style.parse(style_definition) # style = Style.parse(style_definition)
self.styles.text = style # self.styles.text = style
except Exception as error: # except Exception as error:
message = f"property 'text' has invalid value {variable_prefix}{style_definition!r}; {error}" # message = f"property 'text' has invalid value {variable_prefix}{style_definition!r}; {error}"
self.error(name, tokens[0], message) # self.error(name, tokens[0], message)
if important: # if important:
self.styles.important.update( # self.styles.important.update(
{"text_style", "text_background", "text_color"} # {"text_style", "text_background", "text_color"}
) # )
def process_text_color( def process_color(self, name: str, tokens: list[Token], important: bool) -> None:
self, name: str, tokens: list[Token], important: bool
) -> None:
for token in tokens: for token in tokens:
if token.name in ("color", "token"): if token.name in ("color", "token"):
try: try:
self.styles._rules["text_color"] = Color.parse(token.value) self.styles._rules["color"] = Color.parse(token.value)
except Exception as error: except Exception as error:
self.error( self.error(
name, token, f"failed to parse color {token.value!r}; {error}" name, token, f"failed to parse color {token.value!r}; {error}"
@@ -498,13 +496,13 @@ class StylesBuilder:
name, token, f"unexpected token {token.value!r} in declaration" name, token, f"unexpected token {token.value!r} in declaration"
) )
def process_text_background( def process_background(
self, name: str, tokens: list[Token], important: bool self, name: str, tokens: list[Token], important: bool
) -> None: ) -> None:
for token in tokens: for token in tokens:
if token.name in ("color", "token"): if token.name in ("color", "token"):
try: try:
self.styles._rules["text_background"] = Color.parse(token.value) self.styles._rules["background"] = Color.parse(token.value)
except Exception as error: except Exception as error:
self.error( self.error(
name, token, f"failed to parse color {token.value!r}; {error}" name, token, f"failed to parse color {token.value!r}; {error}"

View File

@@ -61,16 +61,16 @@ class RulesMap(TypedDict, total=False):
Any key may be absent, indiciating that rule has not been set. Any key may be absent, indiciating that rule has not been set.
Does not define composite rules, that is a rule that is made of a combination of other rules. For instance, Does not define composite rules, that is a rule that is made of a combination of other rules.
the text style is made up of text_color, text_background, and text_style.
""" """
display: Display display: Display
visibility: Visibility visibility: Visibility
layout: "Layout" layout: "Layout"
text_color: Color color: Color
text_background: Color background: Color
text_style: Style text_style: Style
opacity: float opacity: float
@@ -132,6 +132,8 @@ class StylesBase(ABC):
"min_height", "min_height",
"max_width", "max_width",
"max_height", "max_height",
"color",
"background",
} }
display = StringEnumProperty(VALID_DISPLAY, "block") display = StringEnumProperty(VALID_DISPLAY, "block")
@@ -139,8 +141,8 @@ class StylesBase(ABC):
layout = LayoutProperty() layout = LayoutProperty()
text = StyleProperty() text = StyleProperty()
text_color = ColorProperty(Color(255, 255, 255)) color = ColorProperty(Color(255, 255, 255))
text_background = ColorProperty(Color(255, 255, 255)) background = ColorProperty(Color(255, 255, 255))
text_style = StyleFlagsProperty() text_style = StyleFlagsProperty()
opacity = FractionalProperty() opacity = FractionalProperty()
@@ -257,14 +259,6 @@ class StylesBase(ABC):
layout (bool, optional): Also require a layout. Defaults to False. layout (bool, optional): Also require a layout. Defaults to False.
""" """
@abstractmethod
def check_refresh(self) -> tuple[bool, bool]:
"""Check if the Styles must be refreshed.
Returns:
tuple[bool, bool]: (repaint required, layout_required)
"""
@abstractmethod @abstractmethod
def reset(self) -> None: def reset(self) -> None:
"""Reset the rules to initial state.""" """Reset the rules to initial state."""
@@ -402,9 +396,6 @@ class Styles(StylesBase):
_rules: RulesMap = field(default_factory=dict) _rules: RulesMap = field(default_factory=dict)
_layout_required: bool = False
_repaint_required: bool = False
important: set[str] = field(default_factory=set) important: set[str] = field(default_factory=set)
def copy(self) -> Styles: def copy(self) -> Styles:
@@ -449,18 +440,8 @@ class Styles(StylesBase):
return self._rules.get(rule, default) return self._rules.get(rule, default)
def refresh(self, *, layout: bool = False) -> None: def refresh(self, *, layout: bool = False) -> None:
self._repaint_required = True if self.node is not None:
self._layout_required = self._layout_required or layout self.node.refresh(layout=layout)
def check_refresh(self) -> tuple[bool, bool]:
"""Check if the Styles must be refreshed.
Returns:
tuple[bool, bool]: (repaint required, layout_required)
"""
result = (self._repaint_required, self._layout_required)
self._repaint_required = self._layout_required = False
return result
def reset(self) -> None: def reset(self) -> None:
"""Reset the rules to initial state.""" """Reset the rules to initial state."""
@@ -637,19 +618,12 @@ class Styles(StylesBase):
assert self.layout is not None assert self.layout is not None
append_declaration("layout", self.layout.name) append_declaration("layout", self.layout.name)
if ( if has_rule("color"):
has_rule("text_color") append_declaration("color", self.color.hex)
and has_rule("text_background") if has_rule("background"):
and has_rule("text_style") append_declaration("background", self.background.hex)
): if has_rule("text_style"):
append_declaration("text", str(self.text)) append_declaration("text-style", str(get_rule("text_style")))
else:
if has_rule("text_color"):
append_declaration("text-color", self.text_color.hex)
if has_rule("text_background"):
append_declaration("text-background", self.text_background.hex)
if has_rule("text_style"):
append_declaration("text-style", str(get_rule("text_style")))
if has_rule("overflow-x"): if has_rule("overflow-x"):
append_declaration("overflow-x", self.overflow_x) append_declaration("overflow-x", self.overflow_x)
@@ -725,17 +699,6 @@ class RenderStyles(StylesBase):
def merge_rules(self, rules: RulesMap) -> None: def merge_rules(self, rules: RulesMap) -> None:
self._inline_styles.merge_rules(rules) self._inline_styles.merge_rules(rules)
def check_refresh(self) -> tuple[bool, bool]:
"""Check if the Styles must be refreshed.
Returns:
tuple[bool, bool]: (repaint required, layout_required)
"""
base_repaint, base_layout = self._base_styles.check_refresh()
inline_repaint, inline_layout = self._inline_styles.check_refresh()
result = (base_repaint or inline_repaint, base_layout or inline_layout)
return result
def reset(self) -> None: def reset(self) -> None:
"""Reset the rules to initial state.""" """Reset the rules to initial state."""
self._inline_styles.reset() self._inline_styles.reset()

View File

@@ -248,12 +248,6 @@ class Stylesheet:
for key in modified_rule_keys: for key in modified_rule_keys:
setattr(base_styles, key, rules.get(key)) setattr(base_styles, key, rules.get(key))
# The styles object may have requested a refresh / layout
# It's the style properties that set these flags
repaint, layout = styles.check_refresh()
if repaint:
node.refresh(layout=layout)
def update(self, root: DOMNode, animate: bool = False) -> None: def update(self, root: DOMNode, animate: bool = False) -> None:
"""Update a node and its children.""" """Update a node and its children."""
apply = self.apply apply = self.apply

View File

@@ -314,8 +314,7 @@ class DOMNode(MessagePump):
for node in self.walk_children(): for node in self.walk_children():
node._css_styles.reset() node._css_styles.reset()
if isinstance(node, Widget): if isinstance(node, Widget):
# node.clear_render_cache() node.set_dirty()
node._repaint_required = True
node._layout_required = True node._layout_required = True
def on_style_change(self) -> None: def on_style_change(self) -> None:

View File

@@ -495,11 +495,11 @@ class Region(NamedTuple):
cut_x ↓ cut_x ↓
┌────────┐┌───┐ ┌────────┐┌───┐
│ ││ │ │ ││ │
││ 0 ││ 1
│ ││ │ │ ││ │
cut_y → └────────┘└───┘ cut_y → └────────┘└───┘
┌────────┐┌───┐ ┌────────┐┌───┐
││ 2 ││ 3
└────────┘└───┘ └────────┘└───┘
Args: Args:
@@ -531,7 +531,7 @@ class Region(NamedTuple):
cut ↓ cut ↓
┌────────┐┌───┐ ┌────────┐┌───┐
││ 0 ││ 1
│ ││ │ │ ││ │
└────────┘└───┘ └────────┘└───┘
@@ -556,10 +556,11 @@ class Region(NamedTuple):
"""Split a region in to two, from a given x offset. """Split a region in to two, from a given x offset.
┌─────────┐ ┌─────────┐
0
│ │ │ │
cut → └─────────┘ cut → └─────────┘
┌─────────┐ ┌─────────┐
│ 1 │
└─────────┘ └─────────┘
Args: Args:

View File

@@ -134,6 +134,7 @@ class Screen(Widget):
widget = message.widget widget = message.widget
assert isinstance(widget, Widget) assert isinstance(widget, Widget)
self._dirty_widgets.append(widget) self._dirty_widgets.append(widget)
self.check_idle()
async def handle_layout(self, message: messages.Layout) -> None: async def handle_layout(self, message: messages.Layout) -> None:
message.stop() message.stop()
@@ -204,10 +205,8 @@ class Screen(Widget):
widget, _region = self.get_widget_at(event.x, event.y) widget, _region = self.get_widget_at(event.x, event.y)
except errors.NoWidget: except errors.NoWidget:
return return
self.log("forward", widget, event)
scroll_widget = widget scroll_widget = widget
if scroll_widget is not None: if scroll_widget is not None:
await scroll_widget.forward_event(event) await scroll_widget.forward_event(event)
else: else:
self.log("view.forwarded", event)
await self.post_message(event) await self.post_message(event)

View File

@@ -84,7 +84,6 @@ class Widget(DOMNode):
self._layout_required = False self._layout_required = False
self._animate: BoundAnimator | None = None self._animate: BoundAnimator | None = None
self._reactive_watches: dict[str, Callable] = {} self._reactive_watches: dict[str, Callable] = {}
self._mouse_over: bool = False
self.highlight_style: Style | None = None self.highlight_style: Style | None = None
self._vertical_scrollbar: ScrollBar | None = None self._vertical_scrollbar: ScrollBar | None = None
@@ -356,7 +355,7 @@ class Widget(DOMNode):
def get_pseudo_classes(self) -> Iterable[str]: def get_pseudo_classes(self) -> Iterable[str]:
"""Pseudo classes for a widget""" """Pseudo classes for a widget"""
if self._mouse_over: if self.mouse_over:
yield "hover" yield "hover"
if self.has_focus: if self.has_focus:
yield "focus" yield "focus"
@@ -472,6 +471,7 @@ class Widget(DOMNode):
def on_style_change(self) -> None: def on_style_change(self) -> None:
self.set_dirty() self.set_dirty()
self.check_idle()
def size_updated( def size_updated(
self, size: Size, virtual_size: Size, container_size: Size self, size: Size, virtual_size: Size, container_size: Size
@@ -575,15 +575,11 @@ class Widget(DOMNode):
Args: Args:
event (events.Idle): Idle event. event (events.Idle): Idle event.
""" """
# Check if the styles have changed
repaint, layout = self.styles.check_refresh()
if self._dirty_regions:
repaint = True
if layout or self.check_layout(): if self.check_layout():
self._reset_check_layout() self._reset_check_layout()
self.screen.post_message_no_wait(messages.Layout(self)) self.screen.post_message_no_wait(messages.Layout(self))
elif repaint: elif self._dirty_regions:
self.emit_no_wait(messages.Update(self, self)) self.emit_no_wait(messages.Update(self, self))
async def focus(self) -> None: async def focus(self) -> None:
@@ -622,6 +618,12 @@ class Widget(DOMNode):
async def on_key(self, event: events.Key) -> None: async def on_key(self, event: events.Key) -> None:
await self.dispatch_key(event) await self.dispatch_key(event)
def on_leave(self) -> None:
self.mouse_over = False
def on_enter(self) -> None:
self.mouse_over = True
def on_mouse_scroll_down(self) -> None: def on_mouse_scroll_down(self) -> None:
self.scroll_down(animate=True) self.scroll_down(animate=True)

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import pytest import pytest
from rich.color import Color
from rich.style import Style from rich.style import Style
from textual.color import Color
from textual.css.errors import StyleTypeError from textual.css.errors import StyleTypeError
from textual.css.styles import Styles, RenderStyles from textual.css.styles import Styles, RenderStyles
from textual.dom import DOMNode from textual.dom import DOMNode
@@ -93,20 +94,20 @@ def test_render_styles_text():
assert styles_view.text == Style() assert styles_view.text == Style()
# Base is bold blue # Base is bold blue
base.text_color = "blue" base.color = "blue"
base.text_style = "bold" base.text_style = "bold"
assert styles_view.text == Style.parse("bold blue") assert styles_view.text == Style.parse("bold blue")
# Base is bold blue, inline is red # Base is bold blue, inline is red
inline.text_color = "red" inline.color = "red"
assert styles_view.text == Style.parse("bold red") assert styles_view.text == Style.parse("bold red")
# Base is bold yellow, inline is red # Base is bold yellow, inline is red
base.text_color = "yellow" base.color = "yellow"
assert styles_view.text == Style.parse("bold red") assert styles_view.text == Style.parse("bold red")
# Base is bold blue # Base is bold blue
inline.text_color = None inline.color = None
assert styles_view.text == Style.parse("bold yellow") assert styles_view.text == Style.parse("bold yellow")
@@ -125,25 +126,28 @@ def test_render_styles_border():
assert styles_view.border_left == ("rounded", Color.parse("green")) assert styles_view.border_left == ("rounded", Color.parse("green"))
assert styles_view.border == ( assert styles_view.border == (
("heavy", Color.parse("red")), ("heavy", Color.parse("red")),
("", Color.default()), ("", Color(0, 255, 0)),
("", Color.default()), ("", Color(0, 255, 0)),
("rounded", Color.parse("green")), ("rounded", Color.parse("green")),
) )
def test_get_opacity_default(): def test_get_opacity_default():
styles = RenderStyles(DOMNode(), Styles(), Styles()) styles = RenderStyles(DOMNode(), Styles(), Styles())
assert styles.opacity == 1. assert styles.opacity == 1.0
@pytest.mark.parametrize("set_value, expected", [ @pytest.mark.parametrize(
[0.2, 0.2], "set_value, expected",
[-0.4, 0.0], [
[5.8, 1.0], [0.2, 0.2],
["25%", 0.25], [-0.4, 0.0],
["-10%", 0.0], [5.8, 1.0],
["120%", 1.0], ["25%", 0.25],
]) ["-10%", 0.0],
["120%", 1.0],
],
)
def test_opacity_set_then_get(set_value, expected): def test_opacity_set_then_get(set_value, expected):
styles = RenderStyles(DOMNode(), Styles(), Styles()) styles = RenderStyles(DOMNode(), Styles(), Styles())
styles.opacity = set_value styles.opacity = set_value