diff --git a/docs/examples/guide/animator/animation01.py b/docs/examples/guide/animator/animation01.py new file mode 100644 index 000000000..d4a504726 --- /dev/null +++ b/docs/examples/guide/animator/animation01.py @@ -0,0 +1,19 @@ +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) + + +if __name__ == "__main__": + app = AnimationApp() + app.run() diff --git a/docs/examples/guide/animator/animation01_static.py b/docs/examples/guide/animator/animation01_static.py new file mode 100644 index 000000000..fde4b6e62 --- /dev/null +++ b/docs/examples/guide/animator/animation01_static.py @@ -0,0 +1,16 @@ +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 + + +if __name__ == "__main__": + app = AnimationApp() + app.run() diff --git a/docs/examples/guide/animator/animation02.py b/docs/examples/guide/animator/animation02.py new file mode 100644 index 000000000..3fbc39de0 --- /dev/null +++ b/docs/examples/guide/animator/animation02.py @@ -0,0 +1,19 @@ +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 new file mode 100644 index 000000000..3ccac893c --- /dev/null +++ b/docs/examples/guide/animator/animation03.py @@ -0,0 +1,21 @@ +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/guide/animator.md b/docs/guide/animator.md index 5fe440d74..b3148fab6 100644 --- a/docs/guide/animator.md +++ b/docs/guide/animator.md @@ -1,3 +1,100 @@ # Animator -TODO: Animator docs +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 and try out different easing functions, run `textual easing` in your terminal. + +!!! note + + The easing preview requires the `dev` extras (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 deals with updating the value of the `opacity` attribute +on the `styles` object. +In a single line, 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. + +In the example above we specified a `duration` of two seconds, but you can alternatively pass in a `speed` value. + +## Animating other attributes + +You can animate non-style attributes on widgets too. +This could be used to drive more complex animations involving styles, or to keep animations in sync with each other. +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 handle the refreshing of the display each time the animator updates the value. + +## Animating arbitrary values + +Sometimes, you'll want to animate a value that isn't directly accessible as an attribute on a widget. +For example, perhaps the value to be animated is nested inside some object structure, and you don't want to restructure your code to make it a top-level attribute. + +In these cases, you can make use of an "unbound" animator. +These are animators which aren't pre-emptively associated with an object. +They let you pass in an object, _and_ the name of the attribute you wish to animate on it. +This is unlike the animators discussed above, which are already _bound_ to the object they were retrieved from. + +## 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. + +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. + +You can preview the different 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="14" +--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. diff --git a/docs/guide/layout.md b/docs/guide/layout.md index adbe5c16f..81e4d7514 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -301,7 +301,7 @@ We'll also add a slight tint using `tint: magenta 40%;` to draw attention to it. === "grid_layout5_col_span.py" - ```python + ```python --8<-- "docs/examples/guide/layout/grid_layout5_col_span.py" ``` @@ -544,7 +544,7 @@ The example below shows how an advanced layout can be built by combining the var === "combining_layouts.css" - ```sass + ```sass --8<-- "docs/examples/guide/layout/combining_layouts.css" ``` diff --git a/src/textual/app.py b/src/textual/app.py index 188a49791..6fd28c1cb 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -663,8 +663,12 @@ class App(Generic[ReturnType], DOMNode): await asyncio.sleep(0.01) for key in press: if key == "_": - print("(pause)") + print("(pause 50ms)") await asyncio.sleep(0.05) + elif key.startswith("wait:"): + _, wait_ms = key.split(":") + print(f"(pause {wait_ms}ms)") + await asyncio.sleep(float(wait_ms) / 1000) else: print(f"press {key!r}") driver.send_event(