From 50e2baba54fd6f57c48d21ebd58be33c0d14cebf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 16 Aug 2022 13:50:11 +0100 Subject: [PATCH 01/12] Add on_complete callback to Animations --- sandbox/darren/just_a_box.py | 39 ++++++++--------------------- src/textual/_animator.py | 24 +++++++++++++++++- src/textual/css/scalar_animation.py | 6 ++--- 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index 781c66f67..b9d0d44ca 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -1,11 +1,9 @@ from __future__ import annotations from rich.console import RenderableType -from rich.panel import Panel from textual import events from textual.app import App, ComposeResult -from textual.layout import Horizontal, Vertical from textual.widget import Widget @@ -18,37 +16,22 @@ class Box(Widget, can_focus=True): super().__init__(*children, id=id, classes=classes) def render(self) -> RenderableType: - return Panel("Box") + return "Box" class JustABox(App): def compose(self) -> ComposeResult: - yield Horizontal( - Vertical( - Box(id="box1", classes="box"), - Box(id="box2", classes="box"), - # Box(id="box3", classes="box"), - # Box(id="box4", classes="box"), - # Box(id="box5", classes="box"), - # Box(id="box6", classes="box"), - # Box(id="box7", classes="box"), - # Box(id="box8", classes="box"), - # Box(id="box9", classes="box"), - # Box(id="box10", classes="box"), - id="left_pane", - ), - Box(id="middle_pane"), - Vertical( - Box(id="boxa", classes="box"), - Box(id="boxb", classes="box"), - Box(id="boxc", classes="box"), - id="right_pane", - ), - id="horizontal", - ) + self.box = Box() + yield self.box - def key_p(self): - print(self.query("#horizontal").first().styles.layout) + def key_a(self): + self.animator.animate( + self.box.styles, + "opacity", + value=0.0, + duration=2.0, + on_complete=self.box.remove, + ) async def on_key(self, event: events.Key) -> None: await self.dispatch_key(event) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index dbc5d9670..4260ef758 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -30,8 +30,20 @@ class Animatable(Protocol): class Animation(ABC): + + on_complete: Callable[[], None] | None = None + """Callback to run after animation completes""" + @abstractmethod def __call__(self, time: float) -> bool: # pragma: no cover + """Call the animation, return a boolean indicating whether animation is in-progress or complete. + + Args: + time (float): The current timestamp + + Returns: + bool: True if the animation has finished, otherwise False. + """ raise NotImplementedError("") def __eq__(self, other: object) -> bool: @@ -48,6 +60,7 @@ class SimpleAnimation(Animation): end_value: float | Animatable final_value: object easing: EasingFunction + on_complete: Callable[[], None] | None = None def __call__(self, time: float) -> bool: @@ -109,6 +122,7 @@ class BoundAnimator: duration: float | None = None, speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, + on_complete: Callable[[], None] | None = None, ) -> None: easing_function = EASING[easing] if isinstance(easing, str) else easing return self._animator.animate( @@ -119,6 +133,7 @@ class BoundAnimator: duration=duration, speed=speed, easing=easing_function, + on_complete=on_complete, ) @@ -163,6 +178,7 @@ class Animator: duration: float | None = None, speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, + on_complete: Callable[[], None] | None = None, ) -> None: """Animate an attribute to a new value. @@ -201,6 +217,7 @@ class Animator: duration=duration, speed=speed, easing=easing_function, + on_complete=on_complete, ) if animation is None: start_value = getattr(obj, attribute) @@ -223,6 +240,7 @@ class Animator: end_value=value, final_value=final_value, easing=easing_function, + on_complete=on_complete, ) assert animation is not None, "animation expected to be non-None" @@ -241,7 +259,11 @@ class Animator: animation_keys = list(self._animations.keys()) for animation_key in animation_keys: animation = self._animations[animation_key] - if animation(animation_time): + animation_complete = animation(animation_time) + if animation_complete: + completion_callback = animation.on_complete + if completion_callback is not None: + completion_callback() del self._animations[animation_key] def _get_time(self) -> float: diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index ae37249f9..e3b9c4d69 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -1,8 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable -from .. import events, log from ..geometry import Offset from .._animator import Animation from .scalar import ScalarOffset @@ -25,6 +24,7 @@ class ScalarAnimation(Animation): duration: float | None, speed: float | None, easing: EasingFunction, + on_complete: Callable[[], None] | None = None, ): assert ( speed is not None or duration is not None @@ -35,6 +35,7 @@ class ScalarAnimation(Animation): self.attribute = attribute self.final_value = value self.easing = easing + self.on_complete = on_complete size = widget.outer_size viewport = widget.app.size @@ -55,7 +56,6 @@ class ScalarAnimation(Animation): eased_factor = self.easing(factor) if eased_factor >= 1: - offset = self.final_value setattr(self.styles, self.attribute, self.final_value) return True From 56a3d83a9ae1baa7bc9cc5269838d6377ba1aff2 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 16 Aug 2022 14:02:13 +0100 Subject: [PATCH 02/12] Add tests for on_complete Animation callbacks --- tests/test_animator.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/test_animator.py b/tests/test_animator.py index fd3f7c038..b431d80b7 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -1,12 +1,10 @@ from __future__ import annotations - from dataclasses import dataclass from unittest.mock import Mock import pytest - from textual._animator import Animator, SimpleAnimation from textual._easing import EASING, DEFAULT_EASING @@ -185,7 +183,6 @@ class MockAnimator(Animator): def test_animator(): - target = Mock() animator = MockAnimator(target) animate_test = AnimateTest() @@ -223,7 +220,6 @@ def test_animator(): def test_bound_animator(): - target = Mock() animator = MockAnimator(target) animate_test = AnimateTest() @@ -245,3 +241,29 @@ def test_bound_animator(): easing=EASING[DEFAULT_EASING], ) assert animator._animations[(id(animate_test), "foo")] == expected + + +def test_animator_on_complete_callback_not_fired_before_duration_ends(): + callback = Mock() + animate_test = AnimateTest() + animator = MockAnimator(Mock()) + + animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback) + + animator._time = 9 + animator() + + assert not callback.called + + +def test_animator_on_complete_callback_fired_at_duration(): + callback = Mock() + animate_test = AnimateTest() + animator = MockAnimator(Mock()) + + animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback) + + animator._time = 10 + animator() + + callback.assert_called_once_with() From 18d375384a2de6ed6460a4178d2cf3dd8dd09db5 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 16 Aug 2022 15:02:48 +0100 Subject: [PATCH 03/12] Update __textual_animation__ signature to include callback --- src/textual/css/styles.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index dd0b53e6f..03478f946 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from functools import lru_cache from operator import attrgetter -from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, cast +from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, cast, Callable import rich.repr from rich.style import Style @@ -579,6 +579,7 @@ class Styles(StylesBase): duration: float | None, speed: float | None, easing: EasingFunction, + on_complete: Callable[[], None] = None, ) -> Animation | None: from ..widget import Widget @@ -594,6 +595,7 @@ class Styles(StylesBase): duration=duration, speed=speed, easing=easing, + on_complete=on_complete, ) return None From 9fe0a5f21c626bcf265c7a8f5ce2ecba4a7dda72 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 16 Aug 2022 15:37:09 +0100 Subject: [PATCH 04/12] Add animation/transition delay support --- sandbox/darren/basic.css | 252 ++++++++++++++++++++++++++++ sandbox/darren/basic.py | 235 ++++++++++++++++++++++++++ sandbox/darren/just_a_box.py | 6 +- sandbox/will/basic.css | 54 +++--- src/textual/_animator.py | 11 +- src/textual/css/scalar_animation.py | 7 +- src/textual/css/styles.py | 6 +- src/textual/css/stylesheet.py | 3 +- tests/css/test_parse.py | 3 +- 9 files changed, 538 insertions(+), 39 deletions(-) create mode 100644 sandbox/darren/basic.css create mode 100644 sandbox/darren/basic.py diff --git a/sandbox/darren/basic.css b/sandbox/darren/basic.css new file mode 100644 index 000000000..b04a978cb --- /dev/null +++ b/sandbox/darren/basic.css @@ -0,0 +1,252 @@ +/* CSS file for basic.py */ + + + + * { + transition: color 300ms linear, background 300ms linear; +} + + +*:hover { + /* tint: 30% red; + /* outline: heavy red; */ +} + +App > Screen { + + background: $surface; + color: $text-surface; + layers: base sidebar; + + color: $text-background; + background: $background; + layout: vertical; + + overflow: hidden; + +} + +#tree-container { + overflow-y: auto; + height: 20; + margin: 1 2; + background: $panel; + padding: 1 2; +} + +DirectoryTree { + padding: 0 1; + height: auto; + +} + + + + +DataTable { + /*border:heavy red;*/ + /* tint: 10% green; */ + /* opacity: 50%; */ + padding: 1; + margin: 1 2; + height: 24; +} + +#sidebar { + color: $text-panel; + background: $panel; + dock: left; + width: 30; + margin-bottom: 1; + offset-x: -100%; + transition: offset 500ms in_out_cubic 2s; + layer: sidebar; +} + +#sidebar.-active { + offset-x: 0; +} + +#sidebar .title { + height: 1; + background: $primary-background-darken-1; + color: $text-primary-background-darken-1; + border-right: wide $background; + content-align: center middle; +} + +#sidebar .user { + height: 8; + background: $panel-darken-1; + color: $text-panel-darken-1; + border-right: wide $background; + content-align: center middle; +} + +#sidebar .content { + background: $panel-darken-2; + color: $text-surface; + border-right: wide $background; + content-align: center middle; +} + + + + +Tweet { + height:12; + width: 100%; + + + background: $panel; + color: $text-panel; + layout: vertical; + /* border: outer $primary; */ + padding: 1; + border: wide $panel; + overflow: auto; + /* scrollbar-gutter: stable; */ + align-horizontal: center; + box-sizing: border-box; +} + + +.scrollable { + overflow-x: auto; + overflow-y: scroll; + margin: 1 2; + height: 24; + align-horizontal: center; + layout: vertical; +} + +.code { + height: auto; + +} + + +TweetHeader { + height:1; + background: $accent; + color: $text-accent +} + +TweetBody { + width: 100%; + background: $panel; + color: $text-panel; + height: auto; + padding: 0 1 0 0; +} + +Tweet.scroll-horizontal TweetBody { + width: 350; +} + +.button { + background: $accent; + color: $text-accent; + width:20; + height: 3; + /* border-top: hidden $accent-darken-3; */ + border: tall $accent-darken-2; + /* border-left: tall $accent-darken-1; */ + + + /* padding: 1 0 0 0 ; */ + + transition: background 400ms in_out_cubic, color 400ms in_out_cubic; + +} + +.button:hover { + background: $accent-lighten-1; + color: $text-accent-lighten-1; + width: 20; + height: 3; + border: tall $accent-darken-1; + /* border-left: tall $accent-darken-3; */ + + + + +} + +#footer { + color: $text-accent; + background: $accent; + height: 1; + + content-align: center middle; + dock:bottom; +} + + +#sidebar .content { + layout: vertical +} + +OptionItem { + height: 3; + background: $panel; + border-right: wide $background; + border-left: blank; + content-align: center middle; +} + +OptionItem:hover { + height: 3; + color: $text-primary; + background: $primary-darken-1; + /* border-top: hkey $accent2-darken-3; + border-bottom: hkey $accent2-darken-3; */ + text-style: bold; + border-left: outer $secondary-darken-2; +} + +Error { + width: 100%; + height:3; + background: $error; + color: $text-error; + border-top: tall $error-darken-2; + border-bottom: tall $error-darken-2; + + padding: 0; + text-style: bold; + align-horizontal: center; +} + +Warning { + width: 100%; + height:3; + background: $warning; + color: $text-warning-fade-1; + border-top: tall $warning-darken-2; + border-bottom: tall $warning-darken-2; + + text-style: bold; + align-horizontal: center; +} + +Success { + width: 100%; + + height:auto; + box-sizing: border-box; + background: $success; + color: $text-success-fade-1; + + border-top: hkey $success-darken-2; + border-bottom: hkey $success-darken-2; + + text-style: bold ; + + align-horizontal: center; +} + + +.horizontal { + layout: horizontal +} diff --git a/sandbox/darren/basic.py b/sandbox/darren/basic.py new file mode 100644 index 000000000..061609238 --- /dev/null +++ b/sandbox/darren/basic.py @@ -0,0 +1,235 @@ +from rich.console import RenderableType + +from rich.syntax import Syntax +from rich.text import Text + +from textual.app import App, ComposeResult +from textual.reactive import Reactive +from textual.widget import Widget +from textual.widgets import Static, DataTable, DirectoryTree, Header, Footer +from textual.layout import Container + +CODE = ''' +from __future__ import annotations + +from typing import Iterable, TypeVar + +T = TypeVar("T") + + +def loop_first(values: Iterable[T]) -> Iterable[tuple[bool, T]]: + """Iterate and generate a tuple with a flag for first value.""" + iter_values = iter(values) + try: + value = next(iter_values) + except StopIteration: + return + yield True, value + for value in iter_values: + yield False, value + + +def loop_last(values: Iterable[T]) -> Iterable[tuple[bool, T]]: + """Iterate and generate a tuple with a flag for last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + for value in iter_values: + yield False, previous_value + previous_value = value + yield True, previous_value + + +def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]: + """Iterate and generate a tuple with a flag for first and last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + first = True + for value in iter_values: + yield first, False, previous_value + first = False + previous_value = value + yield first, True, previous_value +''' + + +lorem_short = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum.""" +lorem = ( + lorem_short + + """ In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """ +) + +lorem_short_text = Text.from_markup(lorem_short) +lorem_long_text = Text.from_markup(lorem * 2) + + +class TweetHeader(Widget): + def render(self) -> RenderableType: + return Text("Lorem Impsum", justify="center") + + +class TweetBody(Widget): + short_lorem = Reactive(False) + + def render(self) -> Text: + return lorem_short_text if self.short_lorem else lorem_long_text + + +class Tweet(Widget): + pass + + +class OptionItem(Widget): + def render(self) -> Text: + return Text("Option") + + +class Error(Widget): + def render(self) -> Text: + return Text("This is an error message", justify="center") + + +class Warning(Widget): + def render(self) -> Text: + return Text("This is a warning message", justify="center") + + +class Success(Widget): + def render(self) -> Text: + return Text("This is a success message", justify="center") + + +class BasicApp(App, css_path="basic.css"): + """A basic app demonstrating CSS""" + + def on_load(self): + """Bind keys here.""" + self.bind("s", "toggle_class('#sidebar', '-active')", description="Sidebar") + self.bind("d", "toggle_dark", description="Dark mode") + self.bind("q", "quit", description="Quit") + self.bind("f", "query_test", description="Query test") + + def compose(self): + yield Header() + + table = DataTable() + self.scroll_to_target = Tweet(TweetBody()) + + yield Container( + Tweet(TweetBody()), + Widget( + Static( + Syntax(CODE, "python", line_numbers=True, indent_guides=True), + classes="code", + ), + classes="scrollable", + ), + table, + Widget(DirectoryTree("~/code/textual"), id="tree-container"), + Error(), + Tweet(TweetBody(), classes="scrollbar-size-custom"), + Warning(), + Tweet(TweetBody(), classes="scroll-horizontal"), + Success(), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + ) + yield Widget( + Widget(classes="title"), + Widget(classes="user"), + OptionItem(), + OptionItem(), + OptionItem(), + Widget(classes="content"), + id="sidebar", + ) + yield Footer() + + table.add_column("Foo", width=20) + table.add_column("Bar", width=20) + table.add_column("Baz", width=20) + table.add_column("Foo", width=20) + table.add_column("Bar", width=20) + table.add_column("Baz", width=20) + table.zebra_stripes = True + for n in range(100): + table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)]) + + def on_mount(self): + self.sub_title = "Widget demo" + + async def on_key(self, event) -> None: + await self.dispatch_key(event) + + def action_toggle_dark(self): + self.dark = not self.dark + + def action_query_test(self): + query = self.query("Tweet") + self.log(query) + self.log(query.nodes) + self.log(query) + self.log(query.nodes) + + query.set_styles("outline: outer red;") + + query = query.exclude(".scroll-horizontal") + self.log(query) + self.log(query.nodes) + + # query = query.filter(".rubbish") + # self.log(query) + # self.log(query.first()) + + async def key_q(self): + await self.shutdown() + + def key_x(self): + self.panic(self.tree) + + def key_escape(self): + self.app.bell() + + def key_t(self): + # Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one. + tweet_body = self.query("TweetBody").first() + tweet_body.short_lorem = not tweet_body.short_lorem + + def key_v(self): + self.get_child(id="content").scroll_to_widget(self.scroll_to_target) + + def key_space(self): + self.bell() + + +app = BasicApp() + +if __name__ == "__main__": + app.run() + + # from textual.geometry import Region + # from textual.color import Color + + # print(Region.intersection.cache_info()) + # print(Region.overlaps.cache_info()) + # print(Region.union.cache_info()) + # print(Region.split_vertical.cache_info()) + # print(Region.__contains__.cache_info()) + # from textual.css.scalar import Scalar + + # print(Scalar.resolve_dimension.cache_info()) + + # from rich.style import Style + # from rich.cells import cached_cell_len + + # print(Style._add.cache_info()) + + # print(cached_cell_len.cache_info()) diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index b9d0d44ca..d665f13eb 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -1,6 +1,7 @@ from __future__ import annotations from rich.console import RenderableType +from rich.panel import Panel from textual import events from textual.app import App, ComposeResult @@ -16,7 +17,7 @@ class Box(Widget, can_focus=True): super().__init__(*children, id=id, classes=classes) def render(self) -> RenderableType: - return "Box" + return Panel("Box") class JustABox(App): @@ -29,7 +30,8 @@ class JustABox(App): self.box.styles, "opacity", value=0.0, - duration=2.0, + duration=1.0, + delay=5.0, on_complete=self.box.remove, ) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index ab50375b6..21c948fa8 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -4,18 +4,18 @@ * { transition: color 300ms linear, background 300ms linear; -} +} *:hover { - /* tint: 30% red; + /* tint: 30% red; /* outline: heavy red; */ } App > Screen { - + background: $surface; - color: $text-surface; + color: $text-surface; layers: base sidebar; color: $text-background; @@ -23,12 +23,12 @@ App > Screen { layout: vertical; overflow: hidden; - + } #tree-container { overflow-y: auto; - height: 20; + height: 20; margin: 1 2; background: $panel; padding: 1 2; @@ -37,7 +37,7 @@ App > Screen { DirectoryTree { padding: 0 1; height: auto; - + } @@ -48,8 +48,8 @@ DataTable { /* tint: 10% green; */ /* opacity: 50%; */ padding: 1; - margin: 1 2; - height: 24; + margin: 1 2; + height: 24; } #sidebar { @@ -59,8 +59,8 @@ DataTable { width: 30; margin-bottom: 1; offset-x: -100%; - - transition: offset 500ms in_out_cubic; + + transition: offset 500ms in_out_cubic 2s; layer: sidebar; } @@ -97,8 +97,8 @@ DataTable { Tweet { height:12; width: 100%; - - + + background: $panel; color: $text-panel; layout: vertical; @@ -121,9 +121,9 @@ Tweet { layout: vertical; } -.code { +.code { height: auto; - + } @@ -133,12 +133,12 @@ TweetHeader { color: $text-accent } -TweetBody { +TweetBody { width: 100%; background: $panel; color: $text-panel; - height: auto; - padding: 0 1 0 0; + height: auto; + padding: 0 1 0 0; } Tweet.scroll-horizontal TweetBody { @@ -158,7 +158,7 @@ Tweet.scroll-horizontal TweetBody { /* padding: 1 0 0 0 ; */ transition: background 400ms in_out_cubic, color 400ms in_out_cubic; - + } .button:hover { @@ -178,7 +178,7 @@ Tweet.scroll-horizontal TweetBody { color: $text-accent; background: $accent; height: 1; - + content-align: center middle; dock:bottom; } @@ -213,7 +213,7 @@ Error { color: $text-error; border-top: tall $error-darken-2; border-bottom: tall $error-darken-2; - + padding: 0; text-style: bold; align-horizontal: center; @@ -226,21 +226,21 @@ Warning { color: $text-warning-fade-1; border-top: tall $warning-darken-2; border-bottom: tall $warning-darken-2; - + text-style: bold; align-horizontal: center; } Success { width: 100%; - - height:auto; + + height:auto; box-sizing: border-box; background: $success; - color: $text-success-fade-1; - + color: $text-success-fade-1; + border-top: hkey $success-darken-2; - border-bottom: hkey $success-darken-2; + border-bottom: hkey $success-darken-2; text-style: bold ; diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 4260ef758..3deb7df9e 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -11,6 +11,7 @@ from . import _clock from ._easing import DEFAULT_EASING, EASING from ._timer import Timer from ._types import MessageTarget +from .geometry import clamp if sys.version_info >= (3, 8): from typing import Protocol, runtime_checkable @@ -56,6 +57,7 @@ class SimpleAnimation(Animation): attribute: str start_time: float duration: float + delay: float start_value: float | Animatable end_value: float | Animatable final_value: object @@ -68,7 +70,7 @@ class SimpleAnimation(Animation): setattr(self.obj, self.attribute, self.final_value) return True - factor = min(1.0, (time - self.start_time) / self.duration) + factor = clamp((time - self.start_time - self.delay) / self.duration, 0.0, 1.0) eased_factor = self.easing(factor) if factor == 1.0: @@ -121,6 +123,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: Callable[[], None] | None = None, ) -> None: @@ -132,6 +135,7 @@ class BoundAnimator: final_value=final_value, duration=duration, speed=speed, + delay=delay, easing=easing_function, on_complete=on_complete, ) @@ -177,6 +181,7 @@ class Animator: final_value: object = ..., duration: float | None = None, speed: float | None = None, + delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: Callable[[], None] | None = None, ) -> None: @@ -189,6 +194,7 @@ class Animator: 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. + delay (float): How long to delay the beginning of the animation by in seconds. easing (EasingFunction | str, optional): An easing function. Defaults to DEFAULT_EASING. """ @@ -202,6 +208,7 @@ class Animator: if final_value is ...: final_value = value + start_time = self._get_time() animation_key = (id(obj), attribute) @@ -217,6 +224,7 @@ class Animator: duration=duration, speed=speed, easing=easing_function, + delay=delay, on_complete=on_complete, ) if animation is None: @@ -236,6 +244,7 @@ class Animator: attribute=attribute, start_time=start_time, duration=animation_duration, + delay=delay, start_value=start_value, end_value=value, final_value=final_value, diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index e3b9c4d69..4880ada1b 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Callable -from ..geometry import Offset +from ..geometry import Offset, clamp from .._animator import Animation from .scalar import ScalarOffset from .._animator import EasingFunction @@ -24,6 +24,7 @@ class ScalarAnimation(Animation): duration: float | None, speed: float | None, easing: EasingFunction, + delay: float = 0.0, on_complete: Callable[[], None] | None = None, ): assert ( @@ -35,6 +36,7 @@ class ScalarAnimation(Animation): self.attribute = attribute self.final_value = value self.easing = easing + self.delay = delay self.on_complete = on_complete size = widget.outer_size @@ -51,8 +53,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.delay) / self.duration, 0.0, 1.0) eased_factor = self.easing(factor) if eased_factor >= 1: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 03478f946..0340e9bad 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -579,12 +579,9 @@ class Styles(StylesBase): duration: float | None, speed: float | None, easing: EasingFunction, + delay: float = 0.0, on_complete: Callable[[], None] = None, ) -> Animation | None: - from ..widget import Widget - - # node = self.node - # assert isinstance(self.node, Widget) if isinstance(value, ScalarOffset): return ScalarAnimation( self.node, @@ -595,6 +592,7 @@ class Styles(StylesBase): duration=duration, speed=speed, easing=easing, + delay=delay, on_complete=on_complete, ) return None diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 06957892e..6d27371dc 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -407,13 +407,14 @@ class Stylesheet: if is_animatable(key) and new_render_value != old_render_value: transition = new_styles.get_transition(key) if transition is not None: - duration, easing, _delay = transition + duration, easing, delay = transition node.app.animator.animate( node.styles.base, key, new_render_value, final_value=new_value, duration=duration, + delay=delay, easing=easing, ) continue diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index 5b11472cf..7e3fe8d9e 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -1040,8 +1040,9 @@ class TestParseTransition: def test_various_duration_formats(self, duration, parsed_duration): easing = "in_out_cubic" transition_property = "offset" + delay = duration css = f"""#some-widget {{ - transition: {transition_property} {duration} {easing} {duration}; + transition: {transition_property} {duration} {easing} {delay}; }} """ stylesheet = Stylesheet() From aefb2e3a807c1f9fac85a599ea10b7dadcdc396f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 16 Aug 2022 15:40:32 +0100 Subject: [PATCH 05/12] Make SimpleAnimation delay a kwarg, defaulting to 0.0 --- src/textual/_animator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 3deb7df9e..c0d9fc6c0 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -57,11 +57,11 @@ class SimpleAnimation(Animation): attribute: str start_time: float duration: float - delay: float start_value: float | Animatable end_value: float | Animatable final_value: object easing: EasingFunction + delay: float = 0.0 on_complete: Callable[[], None] | None = None def __call__(self, time: float) -> bool: From cac4822e888585ae67c1b4747ac52ae6fcbeeab3 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 16 Aug 2022 16:02:11 +0100 Subject: [PATCH 06/12] Add test for delays --- tests/test_animator.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_animator.py b/tests/test_animator.py index b431d80b7..e7918f7af 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -267,3 +267,21 @@ def test_animator_on_complete_callback_fired_at_duration(): animator() callback.assert_called_once_with() + + +def test_animator_delay(): + callback = Mock() + animate_test = AnimateTest() + animator = MockAnimator(Mock()) + + animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback, delay=2.0) + + # on_complete callback hasn't been called at 11 seconds, + # since delay=2, and duration=10. It will be called at 12s. + animator._time = 11 + animator() + assert not callback.called + + animator._time = 13 + animator() + callback.assert_called_once_with() From 0455dacfdd68584b4bcc63b1485a0b2974714c99 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 30 Aug 2022 15:51:26 +0100 Subject: [PATCH 07/12] Smarter delays - animator doesnt need to work during delay period --- sandbox/darren/just_a_box.css | 2 +- sandbox/darren/just_a_box.py | 3 +- src/textual/_animator.py | 85 ++++++++++++++++++++++++++--- src/textual/css/scalar_animation.py | 12 ++-- src/textual/css/stylesheet.py | 1 + 5 files changed, 86 insertions(+), 17 deletions(-) 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 From 6734700de2ea4553297a0588927bb20f1d76d8e9 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 30 Aug 2022 15:56:24 +0100 Subject: [PATCH 08/12] Testing animation delay in CSS and code --- sandbox/darren/just_a_box.css | 22 ++++++++++------------ sandbox/darren/just_a_box.py | 4 ++++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/sandbox/darren/just_a_box.css b/sandbox/darren/just_a_box.css index 881e436bf..bd3e92d9b 100644 --- a/sandbox/darren/just_a_box.css +++ b/sandbox/darren/just_a_box.css @@ -2,20 +2,18 @@ Screen { background: lightcoral; } -#left_pane { - background: red; - width: 20; - overflow: scroll scroll; +#sidebar { + color: $text-panel; + background: $panel; + dock: left; + width: 30; + offset-x: -100%; + transition: offset 500ms in_out_cubic 2s; + layer: sidebar; } -#middle_pane { - background: green; - width: 140; -} - -#right_pane { - background: blue; - width: 30; +#sidebar.-active { + offset-x: 0; } .box { diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index 1410f291c..094dc5c09 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -20,9 +20,13 @@ class Box(Widget, can_focus=True): class JustABox(App): + def on_load(self): + self.bind("s", "toggle_class('#sidebar', '-active')", description="Sidebar") + def compose(self) -> ComposeResult: self.box = Box() yield self.box + yield Widget(id="sidebar") def key_a(self): self.animator.animate( From 869116a85431396e7db75f3ae79c56124248f659 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 30 Aug 2022 16:10:23 +0100 Subject: [PATCH 09/12] Remove some test code --- src/textual/_animator.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index f7db22385..cfeb5bc34 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -317,29 +317,3 @@ 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()) From f56c415c6ad57aafebcc96e63f9b0ffbe1bfe1e0 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 30 Aug 2022 16:12:56 +0100 Subject: [PATCH 10/12] Use `min` instead of `clamp` to find current animation progress --- src/textual/css/scalar_animation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index d7828447d..c584ef692 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -6,7 +6,7 @@ from .scalar import ScalarOffset from .._animator import Animation from .._animator import EasingFunction from .._types import CallbackType -from ..geometry import Offset, clamp +from ..geometry import Offset if TYPE_CHECKING: from ..widget import Widget @@ -51,7 +51,7 @@ class ScalarAnimation(Animation): self.duration = duration def __call__(self, time: float) -> bool: - factor = clamp((time - self.start_time) / self.duration, 0.0, 1.0) + factor = min(1.0, (time - self.start_time) / self.duration) eased_factor = self.easing(factor) if eased_factor >= 1: From 3865af86f8dabb17599b17eef916d79793103cbc Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 11:28:17 +0100 Subject: [PATCH 11/12] Rename Animator.target to app --- src/textual/_animator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index cfeb5bc34..2467b0efe 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -143,13 +143,13 @@ class BoundAnimator: class Animator: """An object to manage updates to a given attribute over a period of time.""" - def __init__(self, target: App, frames_per_second: int = 60) -> None: + def __init__(self, app: App, frames_per_second: int = 60) -> None: self._animations: dict[tuple[object, str], Animation] = {} - self.target = target + self.app = app self._timer = Timer( - target, + app, 1 / frames_per_second, - target, + app, name="Animator", callback=self, pause=True, @@ -209,7 +209,7 @@ class Animator: on_complete=on_complete, ) if delay: - self.target.set_timer(delay, animate_callback) + self.app.set_timer(delay, animate_callback) else: animate_callback() From 665b4501bd35b4862806d627f8c92cfe871d74d1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Aug 2022 11:46:12 +0100 Subject: [PATCH 12/12] fix for justify --- src/textual/widget.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 0a0d357ac..3f0e2f2bb 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1210,16 +1210,19 @@ class Widget(DOMNode): RenderableType: A new renderable. """ + text_justify = ( + _get_rich_justify(self.styles.text_align) + if self.styles.has_rule("text_align") + else None + ) if isinstance(renderable, str): - justify = _get_rich_justify(self.styles.text_align) - renderable = Text.from_markup(renderable, justify=justify) + renderable = Text.from_markup(renderable, justify=text_justify) rich_style = self.rich_style if isinstance(renderable, Text): renderable.stylize(rich_style) - if not renderable.justify: - justify = _get_rich_justify(self.styles.text_align) - renderable.justify = justify + if text_justify is not None and renderable.justify is None: + renderable.justify = text_justify else: renderable = Styled(renderable, rich_style)