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

View file

@ -1,7 +1,7 @@
#uber1 {
layout: vertical;
text-background: dark_green;
background: dark_green;
overflow: hidden auto;
border: heavy white;
}
@ -9,5 +9,5 @@
.list-item {
height: 8;
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
animation: Animation
animation: Animation | None = None
if hasattr(obj, "__textual_animation__"):
animation = getattr(obj, "__textual_animation__")(
attribute,
@ -196,7 +196,7 @@ class Animator:
speed=speed,
easing=easing_function,
)
else:
if animation is None:
start_value = getattr(obj, attribute)
if start_value == value:

View file

@ -720,4 +720,4 @@ class App(DOMNode):
self.screen.query(selector).toggle_class(class_name)
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
from typing import NamedTuple
from .geometry import clamp
import rich.repr
from rich.color import Color as RichColor
from rich.style import Style
from . import log
from .geometry import clamp
ANSI_COLOR_NAMES = {
"black": 0,
"red": 1,
@ -534,16 +537,29 @@ class Color(NamedTuple):
@classmethod
def from_rich_color(cls, rich_color: RichColor) -> Color:
"""Create color from Rich's color class."""
r, g, b = rich_color.get_truecolor()
return cls(r, g, b)
@property
def rich_color(self) -> RichColor:
"""This color encoded in Rich's Color class."""
r, g, b, _a = self
return RichColor.from_rgb(r, g, b)
@property
def is_transparent(self) -> bool:
"""Check if the color is transparent."""
return self.a == 0
@property
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
return (
f"#{r:02X}{g:02X}{b:02X}"
@ -553,6 +569,12 @@ class Color(NamedTuple):
@property
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
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 "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
@lru_cache(maxsize=1024 * 4)
def parse(cls, color_text: str) -> Color:
@ -587,7 +628,10 @@ class Color(NamedTuple):
if rgb_hex is not None:
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:
color = cls(

View file

@ -15,7 +15,7 @@ import rich.repr
from rich.style import Style
from .. import log
from ..color import Color
from ..color import Color, ColorPair
from ._error_tools import friendly_list
from .constants import NULL_SPACING
from .errors import StyleTypeError, StyleValueError
@ -316,46 +316,9 @@ class StyleProperty:
Returns:
A ``Style`` object.
"""
has_rule = obj.has_rule
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
style = ColorPair(obj.color, obj.background).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:
"""Descriptor for getting and setting spacing properties (e.g. padding and margin)."""
@ -660,9 +623,7 @@ class NameListProperty:
) -> tuple[str, ...]:
return obj.get_rule(self.name, ())
def __set__(
self, obj: StylesBase, names: str | tuple[str] | None = None
) -> str | tuple[str] | None:
def __set__(self, obj: StylesBase, names: str | tuple[str] | None = None):
if names is None:
if obj.clear_rule(self.name):
@ -687,7 +648,7 @@ class ColorProperty:
self.name = name
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:
obj (Styles): The ``Styles`` object.
@ -696,7 +657,7 @@ class ColorProperty:
Returns:
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):
"""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())})",
)
def process_text(self, name: str, tokens: list[Token], important: bool) -> None:
style_definition = _join_tokens(tokens, joiner=" ")
# def process_text(self, name: str, tokens: list[Token], important: bool) -> None:
# 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.
# TODO: Factor this out to apply it to other properties too.
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:
variable_prefix = f"${tokens[0].referenced_by.name}="
else:
variable_prefix = ""
# # If every token in the value is a referenced by the same variable,
# # we can display the variable name before the style definition.
# # TODO: Factor this out to apply it to other properties too.
# 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:
# variable_prefix = f"${tokens[0].referenced_by.name}="
# else:
# variable_prefix = ""
try:
style = Style.parse(style_definition)
self.styles.text = style
except Exception as error:
message = f"property 'text' has invalid value {variable_prefix}{style_definition!r}; {error}"
self.error(name, tokens[0], message)
if important:
self.styles.important.update(
{"text_style", "text_background", "text_color"}
)
# try:
# style = Style.parse(style_definition)
# self.styles.text = style
# except Exception as error:
# message = f"property 'text' has invalid value {variable_prefix}{style_definition!r}; {error}"
# self.error(name, tokens[0], message)
# if important:
# self.styles.important.update(
# {"text_style", "text_background", "text_color"}
# )
def process_text_color(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_color(self, name: str, tokens: list[Token], important: bool) -> None:
for token in tokens:
if token.name in ("color", "token"):
try:
self.styles._rules["text_color"] = Color.parse(token.value)
self.styles._rules["color"] = Color.parse(token.value)
except Exception as error:
self.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"
)
def process_text_background(
def process_background(
self, name: str, tokens: list[Token], important: bool
) -> None:
for token in tokens:
if token.name in ("color", "token"):
try:
self.styles._rules["text_background"] = Color.parse(token.value)
self.styles._rules["background"] = Color.parse(token.value)
except Exception as error:
self.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.
Does not define composite rules, that is a rule that is made of a combination of other rules. For instance,
the text style is made up of text_color, text_background, and text_style.
Does not define composite rules, that is a rule that is made of a combination of other rules.
"""
display: Display
visibility: Visibility
layout: "Layout"
text_color: Color
text_background: Color
color: Color
background: Color
text_style: Style
opacity: float
@ -132,6 +132,8 @@ class StylesBase(ABC):
"min_height",
"max_width",
"max_height",
"color",
"background",
}
display = StringEnumProperty(VALID_DISPLAY, "block")
@ -139,8 +141,8 @@ class StylesBase(ABC):
layout = LayoutProperty()
text = StyleProperty()
text_color = ColorProperty(Color(255, 255, 255))
text_background = ColorProperty(Color(255, 255, 255))
color = ColorProperty(Color(255, 255, 255))
background = ColorProperty(Color(255, 255, 255))
text_style = StyleFlagsProperty()
opacity = FractionalProperty()
@ -257,14 +259,6 @@ class StylesBase(ABC):
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
def reset(self) -> None:
"""Reset the rules to initial state."""
@ -402,9 +396,6 @@ class Styles(StylesBase):
_rules: RulesMap = field(default_factory=dict)
_layout_required: bool = False
_repaint_required: bool = False
important: set[str] = field(default_factory=set)
def copy(self) -> Styles:
@ -449,18 +440,8 @@ class Styles(StylesBase):
return self._rules.get(rule, default)
def refresh(self, *, layout: bool = False) -> None:
self._repaint_required = True
self._layout_required = self._layout_required or 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
if self.node is not None:
self.node.refresh(layout=layout)
def reset(self) -> None:
"""Reset the rules to initial state."""
@ -637,19 +618,12 @@ class Styles(StylesBase):
assert self.layout is not None
append_declaration("layout", self.layout.name)
if (
has_rule("text_color")
and has_rule("text_background")
and has_rule("text_style")
):
append_declaration("text", str(self.text))
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("color"):
append_declaration("color", self.color.hex)
if has_rule("background"):
append_declaration("background", self.background.hex)
if has_rule("text_style"):
append_declaration("text-style", str(get_rule("text_style")))
if has_rule("overflow-x"):
append_declaration("overflow-x", self.overflow_x)
@ -725,17 +699,6 @@ class RenderStyles(StylesBase):
def merge_rules(self, rules: RulesMap) -> None:
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:
"""Reset the rules to initial state."""
self._inline_styles.reset()

View file

@ -248,12 +248,6 @@ class Stylesheet:
for key in modified_rule_keys:
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:
"""Update a node and its children."""
apply = self.apply

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
import pytest
from rich.color import Color
from rich.style import Style
from textual.color import Color
from textual.css.errors import StyleTypeError
from textual.css.styles import Styles, RenderStyles
from textual.dom import DOMNode
@ -93,20 +94,20 @@ def test_render_styles_text():
assert styles_view.text == Style()
# Base is bold blue
base.text_color = "blue"
base.color = "blue"
base.text_style = "bold"
assert styles_view.text == Style.parse("bold blue")
# Base is bold blue, inline is red
inline.text_color = "red"
inline.color = "red"
assert styles_view.text == Style.parse("bold red")
# Base is bold yellow, inline is red
base.text_color = "yellow"
base.color = "yellow"
assert styles_view.text == Style.parse("bold red")
# Base is bold blue
inline.text_color = None
inline.color = None
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 == (
("heavy", Color.parse("red")),
("", Color.default()),
("", Color.default()),
("", Color(0, 255, 0)),
("", Color(0, 255, 0)),
("rounded", Color.parse("green")),
)
def test_get_opacity_default():
styles = RenderStyles(DOMNode(), Styles(), Styles())
assert styles.opacity == 1.
assert styles.opacity == 1.0
@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],
])
@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