diff --git a/sandbox/darren/just_a_box.css b/sandbox/darren/just_a_box.css index 3a09b9c02..881e436bf 100644 --- a/sandbox/darren/just_a_box.css +++ b/sandbox/darren/just_a_box.css @@ -4,7 +4,7 @@ Screen { #left_pane { background: red; - width: 20 + width: 20; overflow: scroll scroll; } diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index ee8be631e..1410f291c 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -1,7 +1,5 @@ from __future__ import annotations -import asyncio - from rich.console import RenderableType from textual import events @@ -32,6 +30,7 @@ class JustABox(App): "opacity", value=0.0, duration=2.0, + delay=2.0, on_complete=self.box.remove, ) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index f20d5a650..f7db22385 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -1,23 +1,25 @@ from __future__ import annotations -from abc import ABC, abstractmethod import asyncio import sys -from typing import Any, Callable, TypeVar - +from abc import ABC, abstractmethod from dataclasses import dataclass +from functools import partial +from typing import Any, Callable, TypeVar, TYPE_CHECKING from . import _clock from ._callback import invoke from ._easing import DEFAULT_EASING, EASING +from ._types import CallbackType from .timer import Timer -from ._types import MessageTarget, CallbackType if sys.version_info >= (3, 8): from typing import Protocol, runtime_checkable else: # pragma: no cover from typing_extensions import Protocol, runtime_checkable +if TYPE_CHECKING: + from textual.app import App EasingFunction = Callable[[float], float] @@ -31,7 +33,6 @@ class Animatable(Protocol): class Animation(ABC): - on_complete: CallbackType | None = None """Callback to run after animation completes""" @@ -64,7 +65,6 @@ class SimpleAnimation(Animation): on_complete: CallbackType | None = None def __call__(self, time: float) -> bool: - if self.duration == 0: setattr(self.obj, self.attribute, self.final_value) return True @@ -122,6 +122,7 @@ class BoundAnimator: 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: @@ -133,6 +134,7 @@ class BoundAnimator: final_value=final_value, duration=duration, speed=speed, + delay=delay, easing=easing_function, on_complete=on_complete, ) @@ -141,7 +143,7 @@ class BoundAnimator: class Animator: """An object to manage updates to a given attribute over a period of time.""" - def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None: + def __init__(self, target: App, frames_per_second: int = 60) -> None: self._animations: dict[tuple[object, str], Animation] = {} self.target = target self._timer = Timer( @@ -179,6 +181,7 @@ class Animator: duration: float | None = None, speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, + delay: float = 0.0, on_complete: CallbackType | None = None, ) -> None: """Animate an attribute to a new value. @@ -191,8 +194,49 @@ class Animator: 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. + delay (float, optional): Number of seconds to delay the start of the animation by. Defaults to 0. + on_complete (CallbackType | None, optional): Callback to run after the animation completes. """ + animate_callback = partial( + self._animate, + obj, + attribute, + value, + final_value=final_value, + duration=duration, + speed=speed, + easing=easing, + on_complete=on_complete, + ) + if delay: + self.target.set_timer(delay, animate_callback) + else: + animate_callback() + def _animate( + self, + obj: object, + attribute: str, + value: Any, + *, + final_value: object = ..., + duration: float | None = None, + speed: float | None = None, + easing: EasingFunction | str = DEFAULT_EASING, + on_complete: CallbackType | None = None, + ): + """Animate an attribute to a new value. + + Args: + 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 .... + 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. + on_complete (CallbackType | None, optional): Callback to run after the animation completes. + """ if not hasattr(obj, attribute): raise AttributeError( f"Can't animate attribute {attribute!r} on {obj!r}; attribute does not exist" @@ -203,6 +247,7 @@ class Animator: if final_value is ...: final_value = value + start_time = self._get_time() animation_key = (id(obj), attribute) @@ -272,3 +317,29 @@ class Animator: # N.B. We could remove this method and always call `self._timer.get_time()` internally, # but it's handy to have in mocking situations return _clock.get_time_no_wait() + + +if __name__ == "__main__": + + async def run(): + async def do(num): + print(num) + + async def delayed(callable, *args): + await callable(*args) + + async def delayed_long(callable, *args): + await asyncio.sleep(5) + await callable(*args) + + tasks = [] + for num in range(10): + if num == 2: + task = asyncio.create_task(delayed_long(do, num)) + else: + task = asyncio.create_task(delayed(do, num)) + tasks.append(task) + + await asyncio.wait(tasks) + + asyncio.run(run()) diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index aa81ec805..d7828447d 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -1,13 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING -from .._types import CallbackType -from ..geometry import Offset -from .._animator import Animation from .scalar import ScalarOffset +from .._animator import Animation from .._animator import EasingFunction - +from .._types import CallbackType +from ..geometry import Offset, clamp if TYPE_CHECKING: from ..widget import Widget @@ -52,8 +51,7 @@ class ScalarAnimation(Animation): self.duration = duration def __call__(self, time: float) -> bool: - - factor = min(1.0, (time - self.start_time) / self.duration) + factor = clamp((time - self.start_time) / self.duration, 0.0, 1.0) eased_factor = self.easing(factor) if eased_factor >= 1: diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index e55db0b74..388c44501 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -2,6 +2,7 @@ from __future__ import annotations import os from collections import defaultdict +from functools import partial from operator import itemgetter from pathlib import Path, PurePath from typing import Iterable, NamedTuple, cast