mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #1167 from Textualize/anim-fix
Fix animation on styles
This commit is contained in:
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- It is now possible to `await` a `DOMQuery.remove`. Note that this changes
|
||||
the return value of `DOMQuery.remove`, which uses to return `self`.
|
||||
https://github.com/Textualize/textual/issues/1094
|
||||
- Added Pilot.wait_for_animation
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -32,6 +33,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
### Fixed
|
||||
|
||||
- Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026
|
||||
- Fixed issues with animation. Now objects of different types may be animated.
|
||||
|
||||
## [0.4.0] - 2022-11-08
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Any, Callable, TypeVar, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
||||
|
||||
from . import _clock
|
||||
from ._callback import invoke
|
||||
@@ -23,6 +23,11 @@ if TYPE_CHECKING:
|
||||
|
||||
EasingFunction = Callable[[float], float]
|
||||
|
||||
|
||||
class AnimationError(Exception):
|
||||
"""An issue prevented animation from starting."""
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -118,7 +123,7 @@ class BoundAnimator:
|
||||
def __call__(
|
||||
self,
|
||||
attribute: str,
|
||||
value: float | Animatable,
|
||||
value: str | float | Animatable,
|
||||
*,
|
||||
final_value: object = ...,
|
||||
duration: float | None = None,
|
||||
@@ -140,6 +145,12 @@ class BoundAnimator:
|
||||
on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None.
|
||||
|
||||
"""
|
||||
start_value = getattr(self._obj, attribute)
|
||||
if isinstance(value, str) and hasattr(start_value, "parse"):
|
||||
# Color and Scalar have a parse method
|
||||
# I'm exploiting a coincidence here, but I think this should be a first-class concept
|
||||
# TODO: add a `Parsable` protocol
|
||||
value = start_value.parse(value)
|
||||
easing_function = EASING[easing] if isinstance(easing, str) else easing
|
||||
return self._animator.animate(
|
||||
self._obj,
|
||||
@@ -270,9 +281,11 @@ class Animator:
|
||||
easing_function = EASING[easing] if isinstance(easing, str) else easing
|
||||
|
||||
animation: Animation | None = None
|
||||
|
||||
if hasattr(obj, "__textual_animation__"):
|
||||
animation = getattr(obj, "__textual_animation__")(
|
||||
attribute,
|
||||
getattr(obj, attribute),
|
||||
value,
|
||||
start_time,
|
||||
duration=duration,
|
||||
@@ -280,7 +293,17 @@ class Animator:
|
||||
easing=easing_function,
|
||||
on_complete=on_complete,
|
||||
)
|
||||
|
||||
if animation is None:
|
||||
|
||||
if not isinstance(value, (int, float)) and not isinstance(
|
||||
value, Animatable
|
||||
):
|
||||
raise AnimationError(
|
||||
f"Don't know how to animate {value!r}; "
|
||||
"Can only animate <int>, <float>, or objects with a blend method"
|
||||
)
|
||||
|
||||
start_value = getattr(obj, attribute)
|
||||
|
||||
if start_value == value:
|
||||
|
||||
@@ -43,7 +43,7 @@ def resolve(
|
||||
(
|
||||
(scalar, None)
|
||||
if scalar.is_fraction
|
||||
else (scalar, scalar.resolve_dimension(size, viewport))
|
||||
else (scalar, scalar.resolve(size, viewport))
|
||||
)
|
||||
for scalar in dimensions
|
||||
]
|
||||
|
||||
@@ -65,7 +65,7 @@ def get_box_model(
|
||||
else:
|
||||
# An explicit width
|
||||
styles_width = styles.width
|
||||
content_width = styles_width.resolve_dimension(
|
||||
content_width = styles_width.resolve(
|
||||
sizing_container - styles.margin.totals, viewport, width_fraction
|
||||
)
|
||||
if is_border_box and styles_width.excludes_border:
|
||||
@@ -73,14 +73,14 @@ def get_box_model(
|
||||
|
||||
if styles.min_width is not None:
|
||||
# Restrict to minimum width, if set
|
||||
min_width = styles.min_width.resolve_dimension(
|
||||
min_width = styles.min_width.resolve(
|
||||
content_container, viewport, width_fraction
|
||||
)
|
||||
content_width = max(content_width, min_width)
|
||||
|
||||
if styles.max_width is not None:
|
||||
# Restrict to maximum width, if set
|
||||
max_width = styles.max_width.resolve_dimension(
|
||||
max_width = styles.max_width.resolve(
|
||||
content_container, viewport, width_fraction
|
||||
)
|
||||
if is_border_box:
|
||||
@@ -100,7 +100,7 @@ def get_box_model(
|
||||
else:
|
||||
styles_height = styles.height
|
||||
# Explicit height set
|
||||
content_height = styles_height.resolve_dimension(
|
||||
content_height = styles_height.resolve(
|
||||
sizing_container - styles.margin.totals, viewport, height_fraction
|
||||
)
|
||||
if is_border_box and styles_height.excludes_border:
|
||||
@@ -108,14 +108,14 @@ def get_box_model(
|
||||
|
||||
if styles.min_height is not None:
|
||||
# Restrict to minimum height, if set
|
||||
min_height = styles.min_height.resolve_dimension(
|
||||
min_height = styles.min_height.resolve(
|
||||
content_container, viewport, height_fraction
|
||||
)
|
||||
content_height = max(content_height, min_height)
|
||||
|
||||
if styles.max_height is not None:
|
||||
# Restrict maximum height, if set
|
||||
max_height = styles.max_height.resolve_dimension(
|
||||
max_height = styles.max_height.resolve(
|
||||
content_container, viewport, height_fraction
|
||||
)
|
||||
content_height = min(content_height, max_height)
|
||||
|
||||
@@ -268,7 +268,7 @@ class Scalar(NamedTuple):
|
||||
return scalar
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def resolve_dimension(
|
||||
def resolve(
|
||||
self, size: Size, viewport: Size, fraction_unit: Fraction | None = None
|
||||
) -> Fraction:
|
||||
"""Resolve scalar with units in to a dimensions.
|
||||
@@ -363,8 +363,8 @@ class ScalarOffset(NamedTuple):
|
||||
"""
|
||||
x, y = self
|
||||
return Offset(
|
||||
round(x.resolve_dimension(size, viewport)),
|
||||
round(y.resolve_dimension(size, viewport)),
|
||||
round(x.resolve(size, viewport)),
|
||||
round(y.resolve(size, viewport)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,25 +2,26 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .scalar import ScalarOffset
|
||||
from .scalar import ScalarOffset, Scalar
|
||||
from .._animator import Animation
|
||||
from .._animator import EasingFunction
|
||||
from .._types import CallbackType
|
||||
from ..geometry import Offset
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
from .styles import Styles
|
||||
from ..dom import DOMNode
|
||||
|
||||
from .styles import StylesBase
|
||||
|
||||
|
||||
class ScalarAnimation(Animation):
|
||||
def __init__(
|
||||
self,
|
||||
widget: Widget,
|
||||
styles: Styles,
|
||||
widget: DOMNode,
|
||||
styles: StylesBase,
|
||||
start_time: float,
|
||||
attribute: str,
|
||||
value: ScalarOffset,
|
||||
value: ScalarOffset | Scalar,
|
||||
duration: float | None,
|
||||
speed: float | None,
|
||||
easing: EasingFunction,
|
||||
@@ -40,8 +41,8 @@ class ScalarAnimation(Animation):
|
||||
size = widget.outer_size
|
||||
viewport = widget.app.size
|
||||
|
||||
self.start: Offset = getattr(styles, attribute).resolve(size, viewport)
|
||||
self.destination: Offset = value.resolve(size, viewport)
|
||||
self.start = getattr(styles, attribute).resolve(size, viewport)
|
||||
self.destination = value.resolve(size, viewport)
|
||||
|
||||
if speed is not None:
|
||||
distance = self.start.get_distance_to(self.destination)
|
||||
@@ -62,7 +63,7 @@ class ScalarAnimation(Animation):
|
||||
value = self.start.blend(self.destination, eased_factor)
|
||||
else:
|
||||
value = self.start + (self.destination - self.start) * eased_factor
|
||||
current = self.styles._rules.get(self.attribute)
|
||||
current = self.styles.get_rule(self.attribute)
|
||||
if current != value:
|
||||
setattr(self.styles, f"{self.attribute}", value)
|
||||
|
||||
|
||||
@@ -300,6 +300,44 @@ class StylesBase(ABC):
|
||||
link_hover_background = ColorProperty("transparent")
|
||||
link_hover_style = StyleFlagsProperty()
|
||||
|
||||
def __textual_animation__(
|
||||
self,
|
||||
attribute: str,
|
||||
start_value: object,
|
||||
value: object,
|
||||
start_time: float,
|
||||
duration: float | None,
|
||||
speed: float | None,
|
||||
easing: EasingFunction,
|
||||
on_complete: CallbackType | None = None,
|
||||
) -> ScalarAnimation | None:
|
||||
if self.node is None:
|
||||
return None
|
||||
|
||||
# Check we are animating a Scalar or Scalar offset
|
||||
if isinstance(start_value, (Scalar, ScalarOffset)):
|
||||
|
||||
# If destination is a number, we can convert that to a scalar
|
||||
if isinstance(value, (int, float)):
|
||||
value = Scalar(value, Unit.CELLS, Unit.CELLS)
|
||||
|
||||
# We can only animate to Scalar
|
||||
if not isinstance(value, (Scalar, ScalarOffset)):
|
||||
return None
|
||||
|
||||
return ScalarAnimation(
|
||||
self.node,
|
||||
self,
|
||||
start_time,
|
||||
attribute,
|
||||
value,
|
||||
duration=duration,
|
||||
speed=speed,
|
||||
easing=easing,
|
||||
on_complete=on_complete,
|
||||
)
|
||||
return None
|
||||
|
||||
def __eq__(self, styles: object) -> bool:
|
||||
"""Check that Styles contains the same rules."""
|
||||
if not isinstance(styles, StylesBase):
|
||||
@@ -627,30 +665,6 @@ class Styles(StylesBase):
|
||||
if self.important:
|
||||
yield "important", self.important
|
||||
|
||||
def __textual_animation__(
|
||||
self,
|
||||
attribute: str,
|
||||
value: Any,
|
||||
start_time: float,
|
||||
duration: float | None,
|
||||
speed: float | None,
|
||||
easing: EasingFunction,
|
||||
on_complete: CallbackType | None = None,
|
||||
) -> ScalarAnimation | None:
|
||||
if isinstance(value, ScalarOffset):
|
||||
return ScalarAnimation(
|
||||
self.node,
|
||||
self,
|
||||
start_time,
|
||||
attribute,
|
||||
value,
|
||||
duration=duration,
|
||||
speed=speed,
|
||||
easing=easing,
|
||||
on_complete=on_complete,
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_border_css_lines(
|
||||
self, rules: RulesMap, name: str
|
||||
) -> Iterable[tuple[str, str]]:
|
||||
@@ -935,7 +949,7 @@ class RenderStyles(StylesBase):
|
||||
def animate(
|
||||
self,
|
||||
attribute: str,
|
||||
value: float | Animatable,
|
||||
value: str | float | Animatable,
|
||||
*,
|
||||
final_value: object = ...,
|
||||
duration: float | None = None,
|
||||
|
||||
@@ -40,8 +40,13 @@ class Pilot:
|
||||
Args:
|
||||
delay (float, optional): Seconds to pause. Defaults to 50ms.
|
||||
"""
|
||||
# These sleep zeros, are to force asyncio to give up a time-slice,
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def wait_for_animation(self) -> None:
|
||||
"""Wait for any animation to complete."""
|
||||
await self._app.animator.wait_for_idle()
|
||||
|
||||
async def exit(self, result: object) -> None:
|
||||
"""Exit the app with the given result.
|
||||
|
||||
|
||||
39
tests/test_animation.py
Normal file
39
tests/test_animation.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from time import perf_counter
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class AnimApp(App):
|
||||
CSS = """
|
||||
#foo {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("foo", id="foo")
|
||||
|
||||
|
||||
async def test_animate_height() -> None:
|
||||
"""Test animating styles.height works."""
|
||||
|
||||
# Styles.height is a scalar, which makes it more complicated to animate
|
||||
|
||||
app = AnimApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
static = app.query_one(Static)
|
||||
assert static.size.height == 1
|
||||
assert static.styles.height.value == 1
|
||||
static.styles.animate("height", 100, duration=0.5, easing="linear")
|
||||
start = perf_counter()
|
||||
|
||||
# Wait for the animation to finished
|
||||
await pilot.wait_for_animation()
|
||||
elapsed = perf_counter() - start
|
||||
# Check that the full time has elapsed
|
||||
assert elapsed >= 0.5
|
||||
# Check the height reached the maximum
|
||||
assert static.styles.height.value == 100
|
||||
Reference in New Issue
Block a user