From 58ed906ed5acf7a9cb6f73bb3278ba7aeb7c7202 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 6 Oct 2022 15:59:42 +0100 Subject: [PATCH] simplified animator docs --- docs/examples/guide/animator/animation02.py | 19 --- docs/examples/guide/animator/animation03.py | 21 --- docs/examples/guide/animator/animation04.py | 29 ---- docs/guide/animation.md | 80 +++++++++++ docs/guide/animator.md | 146 -------------------- mkdocs.yml | 4 +- src/textual/_animator.py | 15 +- src/textual/app.py | 40 +++++- src/textual/css/styles.py | 44 ++++-- src/textual/widget.py | 45 ++++-- 10 files changed, 201 insertions(+), 242 deletions(-) delete mode 100644 docs/examples/guide/animator/animation02.py delete mode 100644 docs/examples/guide/animator/animation03.py delete mode 100644 docs/examples/guide/animator/animation04.py create mode 100644 docs/guide/animation.md delete mode 100644 docs/guide/animator.md diff --git a/docs/examples/guide/animator/animation02.py b/docs/examples/guide/animator/animation02.py deleted file mode 100644 index 3fbc39de0..000000000 --- a/docs/examples/guide/animator/animation02.py +++ /dev/null @@ -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() diff --git a/docs/examples/guide/animator/animation03.py b/docs/examples/guide/animator/animation03.py deleted file mode 100644 index 3ccac893c..000000000 --- a/docs/examples/guide/animator/animation03.py +++ /dev/null @@ -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() diff --git a/docs/examples/guide/animator/animation04.py b/docs/examples/guide/animator/animation04.py deleted file mode 100644 index 5fbbb7fe8..000000000 --- a/docs/examples/guide/animator/animation04.py +++ /dev/null @@ -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() diff --git a/docs/guide/animation.md b/docs/guide/animation.md new file mode 100644 index 000000000..f226feedb --- /dev/null +++ b/docs/guide/animation.md @@ -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. diff --git a/docs/guide/animator.md b/docs/guide/animator.md deleted file mode 100644 index a9ad5e9fe..000000000 --- a/docs/guide/animator.md +++ /dev/null @@ -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. diff --git a/mkdocs.yml b/mkdocs.yml index 23c5d2742..6b2fd4854 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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" diff --git a/src/textual/_animator.py b/src/textual/_animator.py index d32c4a01f..b628a23f7 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -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. diff --git a/src/textual/app.py b/src/textual/app.py index 491522b0f..5a1f7c1d0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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. diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index ebc59df8c..9494de324 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -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: diff --git a/src/textual/widget.py b/src/textual/widget.py index 4dd7089d6..9b88fc745 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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: