simplified animator docs

This commit is contained in:
Will McGugan
2022-10-06 15:59:42 +01:00
parent 8bd1dcd7dd
commit 58ed906ed5
10 changed files with 201 additions and 242 deletions

View File

@@ -1,19 +0,0 @@
from textual.app import App, ComposeResult
from textual.widgets import Static
class AnimationApp(App):
def compose(self) -> ComposeResult:
self.box = Static("Hello, World!")
self.box.styles.background = "red"
self.box.styles.color = "black"
self.box.styles.padding = (1, 2)
yield self.box
def on_mount(self):
self.box.styles.animate("opacity", value=0.0, duration=2.0, easing="linear")
if __name__ == "__main__":
app = AnimationApp()
app.run()

View File

@@ -1,21 +0,0 @@
from textual.app import App, ComposeResult
from textual.widgets import Static
class AnimationApp(App):
def compose(self) -> ComposeResult:
self.box = Static("Hello, World!")
self.box.styles.background = "red"
self.box.styles.color = "black"
self.box.styles.padding = (1, 2)
yield self.box
def on_mount(self):
self.box.styles.animate(
"opacity", value=0.0, duration=2.0, on_complete=self.bell
)
if __name__ == "__main__":
app = AnimationApp()
app.run()

View File

@@ -1,29 +0,0 @@
from rich.console import RenderableType
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widget import Widget
class ValueBox(Widget):
value = reactive(0.0)
def render(self) -> RenderableType:
return str(self.value)
class AnimationApp(App):
def compose(self) -> ComposeResult:
self.box = ValueBox()
self.box.styles.background = "red"
self.box.styles.color = "black"
self.box.styles.padding = (1, 2)
yield self.box
async def on_mount(self):
self.box.animate("value", value=100.0, duration=100.0, easing="linear")
if __name__ == "__main__":
app = AnimationApp()
app.run()

80
docs/guide/animation.md Normal file
View File

@@ -0,0 +1,80 @@
# Animation
Ths chapter discusses how to use Textual's animation system.
## Animating styles
Textual's animator can change an attribute from one value to another in fixed increments over a period of time. You can apply this to [styles](styles.md) such `offset` to move widgets around the screen, and `opacity` to create fading effects.
Apps and widgets both have a [animate][textual.app.App.animate] method which will animate properties on those objects. Additionally, `styles` objects have an identical `animate` method which will animate styles.
Let's look at an example of how we can animate the opacity of a widget to make it fade out.
The following example app contains a single `Static` widget which is immediately animated to an opacity of `0.0` (making it invisible) over a duration of two seconds.
```python hl_lines="14"
--8<-- "docs/examples/guide/animator/animation01.py"
```
The animator updates the value of the `opacity` attribute on the `styles` object in small increments over two seconds. Here's what the output will look like after each half a second.
=== "After 0s"
```{.textual path="docs/examples/guide/animator/animation01_static.py"}
```
=== "After 0.5s"
```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:500"}
```
=== "After 1s"
```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:1000"}
```
=== "After 1.5s"
```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:1500"}
```
=== "After 2s"
```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:2000"}
```
## Duration and Speed
When requesting an animation you can specify a *duration* or *speed*.
The duration is how long the animation should take in seconds. The speed is how many units a value should change in one second.
For instance, if you animate a value at 0 to 10 with a speed of 2, it will complete in 5 seconds.
## Easing functions
The easing function determines the journey a value takes on its way to the target value.
It could move at a constant pace, or it might start off slow then accelerate towards its final value.
Textual supports a number of [easing functions](https://easings.net/). Run the following from the command prompt to preview them.
```bash
textual easing
```
You can specify which easing method to use via the `easing` parameter on the `animate` method. The default easing method is `"in_out_cubic"` which accelerates and then decelerates to produce a pleasing organic motion.
!!! note
The `textual easing` preview requires the `dev` extras to be installed (using `pip install textual[dev]`).
## Completion callbacks
You can pass an callable to the animator via the `on_complete` parameter. Textual will run the callable when the animation has completed.
## Delaying animations
You can delay the start of an animation with the `delay` parameter of the `animate` method.
This parameter accepts a `float` value representing the number of seconds to delay the animation by.
For example, `self.box.styles.animate("opacity", value=0.0, duration=2.0, delay=5.0)` delays the start of the animation by five seconds,
meaning the animation will start after 5 seconds and complete 2 seconds after that.

View File

@@ -1,146 +0,0 @@
# Animator
Textual ships with an easy-to-use system which lets you add animation to your application.
To get a feel for what animation looks like in Textual, run `textual easing` from the command line.
!!! note
The `textual easing` preview requires the `dev` extras to be installed (using `pip install textual[dev]`).
## Animating styles
The animator allows you to easily animate the attributes of a widget, including the `styles`.
This means you can animate attributes such as `offset` to move widgets around,
and `opacity` to create "fading" effects.
To animate something, you need a reference to an "animator".
Conveniently, you can obtain an animator via the `animate` property on `App`, `Widget` and `RenderStyles` (the type of `widget.styles`).
Let's look at an example of how we can animate the opacity of a widget to make it fade out.
The app below contains a single `Static` widget which is immediately animated to an opacity of `0.0` over a duration of two seconds.
```python hl_lines="14"
--8<-- "docs/examples/guide/animator/animation01.py"
```
Internally, the animator repeatedly updates the value of the `opacity` attribute on the `styles` object.
With a single line of code, we've achieved a fading animation:
=== "After 0s"
```{.textual path="docs/examples/guide/animator/animation01_static.py"}
```
=== "After 1s"
```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:1000"}
```
=== "After 2s"
```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:2100"}
```
Remember, when the value of a property on the `styles` object gets updated, Textual automatically updates the display.
This means there's no additional code required to trigger a display update - the animation just works.
In the example above we specified a `duration` of two seconds, but you can alternatively pass in a `speed` value.
## The `Animatable` protocol
You can animate `float` values and any type which implements the `Animatable` protocol.
To implement the `Animatable` protocol, add a `def blend(self: T, destination: T, factor: float) -> T` method to the class.
The `blend` method should return a new object which represents `self` blended with `destination` by a factor of `factor`.
The animator will repeatedly call this method to retrieve the current animated value.
An example of an object which implements this protocol is [Color][textual.color.Color].
It follows that you can use `animate` to animate from one `Color` to another.
## Animating widget attributes
You can animate non-`style` attributes on widgets too, assuming they are `floats` or are `Animatable`.
Again, the animation system will take care of updating the attribute on the widget as time progresses.
If the attribute being animated is [reactive](./reactivity.md), Textual can refresh the display each time the animator updates the value.
The example below shows a simple incrementing timer that counts from 0 to 100 over 100 seconds.
=== "animation04.py"
```python
--8<-- "docs/examples/guide/animator/animation04.py"
```
=== "Output"
```{.textual path="docs/examples/guide/animator/animation04.py"}
```
Since `value` is reactive, the display is automatically updated each time the animator modifies it.
## Animating Python object attributes
Sometimes you'll want to animate a value that exists inside a plain old Python object.
In these cases, you can make use of the "unbound" animator.
An unbound animator is an animator which isn't pre-emptively associated with (bound to) an object.
Unbound animators let you pass the name of the attribute you wish to animate, _and_ the object that attribute exists on.
This is unlike the animators discussed above, which are already _bound_ to the object they were retrieved from.
You can retrieve the unbound animator from the `App` instance via `App.animator`, and call the `animate` method on it.
This method is the same as the one described earlier, except the first argument is the object containing the attribute.
## Easing functions
Easing functions control the "look and feel" of an animation.
The easing function determines the journey a value takes on its way to the target value.
Perhaps the value will be transformed linearly, moving towards the target at a constant rate.
Or maybe it'll start off slow, then accelerate towards the final value as the animation progresses.
Easing functions take a single input representing the time, and output a "factor".
This factor is what gets passed to the `blend` method in the `Animatable` protocol.
!!! warning
The factor output by the easing function will usually remain between 0 and 1.
However, some easing functions (such as `in_out_elastic`) will produce values slightly below 0 and slightly above 1.
Because of this, any implementation of `blend` should support values outwith the range 0 to 1.
Textual supports the easing functions listed on this [very helpful page](https://easings.net/).
In order to use them, you'll need to write them as `snake_case` and remove the `ease` at the start.
To use `easeInOutSine`, for example, you'll write `in_out_sine`.
The example below shows how we can use the `linear` easing function to ensure our box fades out at a constant rate.
```python hl_lines="14"
--8<-- "docs/examples/guide/animator/animation02.py"
```
Note that the only change we had to make was to pass `easing="linear"` into the `animate` method.
!!! note
If you wish to use a custom easing function, you can pass a callable that accepts a `float` as input and returns a `float` as the argument for `easing`.
You can preview the built-in easing functions by running `textual easing`, and clicking the buttons on the left of the window.
## Completion callbacks
To run some code when the animation completes, you can pass a callable object as the `on_complete` argument to the `animate` method.
Here's how we might extend the example above to ring the terminal bell when the animation ends:
```python hl_lines="15"
--8<-- "docs/examples/guide/animator/animation03.py"
```
Awaitable callbacks are also supported.
If the callback passed to `on_complete` is awaitable, then Textual will await it for you.
## Delaying animations
You can delay the start of an animation using the `delay` parameter of the `animate` method.
This parameter accepts a `float` value representing the number of seconds to delay the animation by.
For example, `self.box.styles.animate("opacity", value=0.0, duration=2.0, delay=5.0)` delays the start of the animation by five seconds,
meaning the total duration between making the call to `animate` and the animation completing is seven seconds.

View File

@@ -20,7 +20,7 @@ nav:
- "guide/actions.md"
- "guide/reactivity.md"
- "guide/widgets.md"
- "guide/animator.md"
- "guide/animation.md"
- "guide/screens.md"
- How to:
- "how-to/index.md"
@@ -95,7 +95,7 @@ nav:
- "widgets/input.md"
- "widgets/static.md"
- "widgets/tree_control.md"
- Reference:
- Reference:
- "reference/app.md"
- "reference/button.md"
- "reference/color.md"

View File

@@ -126,6 +126,19 @@ class BoundAnimator:
easing: EasingFunction | str = DEFAULT_EASING,
on_complete: CallbackType | None = None,
) -> None:
"""Animate an attribute.
Args:
attribute (str): Name of the attribute to animate.
value (float | Animatable): The value to animate to.
final_value (object, optional): The final value of the animation. Defaults to `value` if not set.
duration (float | None, optional): The duration of the animate. Defaults to None.
speed (float | None, optional): The speed of the animation. Defaults to None.
delay (float, optional): A delay (in seconds) before the animation starts. Defaults to 0.0.
easing (EasingFunction | str, optional): An easing method. Defaults to "in_out_cubic".
on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None.
"""
easing_function = EASING[easing] if isinstance(easing, str) else easing
return self._animator.animate(
self._obj,
@@ -191,7 +204,7 @@ class Animator:
obj (object): The object containing the attribute.
attribute (str): The name of the attribute.
value (Any): The destination value of the attribute.
final_value (Any, optional): The final value, or ellipsis if it is the same as ``value``. Defaults to ....
final_value (Any, optional): The final value, or ellipsis if it is the same as ``value``. Defaults to Ellipsis/
duration (float | None, optional): The duration of the animation, or ``None`` to use speed. Defaults to ``None``.
speed (float | None, optional): The speed of the animation. Defaults to None.
easing (EasingFunction | str, optional): An easing function. Defaults to DEFAULT_EASING.

View File

@@ -31,7 +31,7 @@ from rich.segment import Segment, Segments
from rich.traceback import Traceback
from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages
from ._animator import Animator
from ._animator import Animator, DEFAULT_EASING, Animatable, EasingFunction
from ._callback import invoke
from ._context import active_app
from ._event_broker import NoHandler, extract_handler_actions
@@ -183,7 +183,7 @@ class App(Generic[ReturnType], DOMNode):
self._action_targets = {"app", "screen"}
self._animator = Animator(self)
self.animate = self._animator.bind(self)
self._animate = self._animator.bind(self)
self.mouse_position = Offset(0, 0)
if title is None:
self._title = f"{self.__class__.__name__}"
@@ -230,6 +230,42 @@ class App(Generic[ReturnType], DOMNode):
sub_title: Reactive[str] = Reactive("")
dark: Reactive[bool] = Reactive(True)
def animate(
self,
attribute: str,
value: float | Animatable,
*,
final_value: object = ...,
duration: float | None = None,
speed: float | None = None,
delay: float = 0.0,
easing: EasingFunction | str = DEFAULT_EASING,
on_complete: CallbackType | None = None,
) -> None:
"""Animate an attribute.
Args:
attribute (str): Name of the attribute to animate.
value (float | Animatable): The value to animate to.
final_value (object, optional): The final value of the animation. Defaults to `value` if not set.
duration (float | None, optional): The duration of the animate. Defaults to None.
speed (float | None, optional): The speed of the animation. Defaults to None.
delay (float, optional): A delay (in seconds) before the animation starts. Defaults to 0.0.
easing (EasingFunction | str, optional): An easing method. Defaults to "in_out_cubic".
on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None.
"""
self._animate(
attribute,
value,
final_value=final_value,
duration=duration,
speed=speed,
delay=delay,
easing=easing,
on_complete=on_complete,
)
@property
def devtools_enabled(self) -> bool:
"""Check if devtools are enabled.

View File

@@ -11,7 +11,7 @@ import rich.repr
from rich.style import Style
from .._types import CallbackType
from .._animator import Animation, EasingFunction, BoundAnimator
from .._animator import BoundAnimator, DEFAULT_EASING, Animatable, EasingFunction
from ..color import Color
from ..geometry import Offset, Spacing
from ._style_properties import (
@@ -889,22 +889,44 @@ class RenderStyles(StylesBase):
assert self.node is not None
return self.node.rich_style
@property
def animate(self) -> BoundAnimator:
"""Get an animator to animate style.
def animate(
self,
attribute: str,
value: float | Animatable,
*,
final_value: object = ...,
duration: float | None = None,
speed: float | None = None,
delay: float = 0.0,
easing: EasingFunction | str = DEFAULT_EASING,
on_complete: CallbackType | None = None,
) -> None:
"""Animate an attribute.
Example:
```python
self.animate("brightness", 0.5)
```
Args:
attribute (str): Name of the attribute to animate.
value (float | Animatable): The value to animate to.
final_value (object, optional): The final value of the animation. Defaults to `value` if not set.
duration (float | None, optional): The duration of the animate. Defaults to None.
speed (float | None, optional): The speed of the animation. Defaults to None.
delay (float, optional): A delay (in seconds) before the animation starts. Defaults to 0.0.
easing (EasingFunction | str, optional): An easing method. Defaults to "in_out_cubic".
on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None.
Returns:
BoundAnimator: An animator bound to this widget.
"""
if self._animate is None:
self._animate = self.node.app.animator.bind(self)
assert self._animate is not None
return self._animate
self._animate(
attribute,
value,
final_value=final_value,
duration=duration,
speed=speed,
delay=delay,
easing=easing,
on_complete=on_complete,
)
def __rich_repr__(self) -> rich.repr.Result:
for rule_name in RULE_NAMES:

View File

@@ -22,7 +22,7 @@ from rich.style import Style
from rich.text import Text
from . import errors, events, messages
from ._animator import BoundAnimator
from ._animator import BoundAnimator, DEFAULT_EASING, Animatable, EasingFunction
from ._arrange import DockArrangeResult, arrange
from ._context import active_app
from ._layout import Layout
@@ -36,6 +36,7 @@ from .dom import DOMNode, NoScreen
from .geometry import Offset, Region, Size, Spacing, clamp
from .layouts.vertical import VerticalLayout
from .message import Message
from .messages import CallbackType
from .reactive import Reactive
from .render import measure
@@ -816,22 +817,44 @@ class Widget(DOMNode):
"""
return active_app.get().console
@property
def animate(self) -> BoundAnimator:
"""Get an animator to animate attributes on this widget.
def animate(
self,
attribute: str,
value: float | Animatable,
*,
final_value: object = ...,
duration: float | None = None,
speed: float | None = None,
delay: float = 0.0,
easing: EasingFunction | str = DEFAULT_EASING,
on_complete: CallbackType | None = None,
) -> None:
"""Animate an attribute.
Example:
```python
self.animate("brightness", 0.5)
```
Args:
attribute (str): Name of the attribute to animate.
value (float | Animatable): The value to animate to.
final_value (object, optional): The final value of the animation. Defaults to `value` if not set.
duration (float | None, optional): The duration of the animate. Defaults to None.
speed (float | None, optional): The speed of the animation. Defaults to None.
delay (float, optional): A delay (in seconds) before the animation starts. Defaults to 0.0.
easing (EasingFunction | str, optional): An easing method. Defaults to "in_out_cubic".
on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None.
Returns:
BoundAnimator: An animator bound to this widget.
"""
if self._animate is None:
self._animate = self.app.animator.bind(self)
assert self._animate is not None
return self._animate
self._animate(
attribute,
value,
final_value=final_value,
duration=duration,
speed=speed,
delay=delay,
easing=easing,
on_complete=on_complete,
)
@property
def _layout(self) -> Layout: