Merge branch 'css' of github.com:Textualize/textual into key-aliases

This commit is contained in:
Darren Burns
2022-10-07 11:41:26 +01:00
13 changed files with 270 additions and 42 deletions

View File

@@ -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()

View File

@@ -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()

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 an [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,3 +0,0 @@
# Animator
TODO: Animator docs

View File

@@ -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"
```

View File

@@ -35,4 +35,4 @@ The example below populates a table with CSV data.
## See Also
* [Table][textual.widgets.DataTable] code reference
* [DataTable][textual.widgets.DataTable] code reference

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

@@ -2,7 +2,7 @@ from __future__ import annotations
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Static, Footer
from textual.widgets import Static, Footer, Header
class JustABox(App):
@@ -12,6 +12,7 @@ class JustABox(App):
]
def compose(self) -> ComposeResult:
yield Header()
yield Static("Hello, world!", id="box1")
yield Footer()

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.
@@ -655,8 +691,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(

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:

View File

@@ -54,6 +54,7 @@ class HeaderTitle(Widget):
HeaderTitle {
content-align: center middle;
width: 100%;
margin-right: 10;
}
"""
@@ -69,7 +70,11 @@ class HeaderTitle(Widget):
class Header(Widget):
"""A header widget with icon and clock."""
"""A header widget with icon and clock.
Args:
show_clock (bool, optional): True if the clock should be shown on the right of the header.
"""
DEFAULT_CSS = """
Header {
@@ -88,10 +93,27 @@ class Header(Widget):
DEFAULT_CLASSES = "-tall"
def __init__(
self,
show_clock: bool = False,
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
):
super().__init__(name=name, id=id, classes=classes)
self.show_clock = show_clock
def compose(self):
yield HeaderIcon()
yield HeaderTitle()
if self.show_clock:
yield HeaderClock()
def watch_tall(self, tall: bool) -> None:
self.set_class(tall, "-tall")
async def on_click(self, event):
def on_click(self):
self.toggle_class("-tall")
def on_mount(self) -> None:
@@ -103,8 +125,3 @@ class Header(Widget):
watch(self.app, "title", set_title)
watch(self.app, "sub_title", set_sub_title)
def compose(self):
yield HeaderIcon()
yield HeaderTitle()
yield HeaderClock()