From d1d6fe28a39e9bade93694cbd3eeca16c48c467f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 23 Jan 2024 13:48:19 +0000 Subject: [PATCH 01/13] Add support for 'TEXTUAL_ANIMATIONS'. --- CHANGELOG.md | 1 + docs/api/constants.md | 1 + mkdocs-nav.yml | 1 + src/textual/constants.py | 51 ++++++++++++++++++++++++++++++++++------ 4 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 docs/api/constants.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a95d744f..7cc97802e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `DOMNode.has_pseudo_classes` https://github.com/Textualize/textual/pull/3970 - Added `Widget.allow_focus` and `Widget.allow_focus_children` https://github.com/Textualize/textual/pull/3989 +- Added support for environment variable `TEXTUAL_ANIMATIONS` to control what animations Textual displays https://github.com/Textualize/textual/issues/3992 ### Fixed diff --git a/docs/api/constants.md b/docs/api/constants.md new file mode 100644 index 0000000000..f4d97e8dfd --- /dev/null +++ b/docs/api/constants.md @@ -0,0 +1 @@ +::: textuals.constants diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index a5ab15880a..ccb858f352 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -178,6 +178,7 @@ nav: - "api/cache.md" - "api/color.md" - "api/command.md" + - "api/constants.md" - "api/containers.md" - "api/content_switcher.md" - "api/coordinate.md" diff --git a/src/textual/constants.py b/src/textual/constants.py index d47d0d2c15..e651986fa2 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -4,6 +4,7 @@ from __future__ import annotations +import enum import os from typing_extensions import Final @@ -11,7 +12,23 @@ get_environ = os.environ.get -def get_environ_bool(name: str) -> bool: +class AnimationsEnum(enum.Enum): + """Valid values for the environment variable `TEXTUAL_ANIMATIONS`. + + - `NONE` disables all animations. + - `BASIC` will only display animations that don't delay content appearing + (e.g., scrolling). + - `FULL` displays all animations. + + See also: [`SHOW_ANIMATIONS`][textual.constants.SHOW_ANIMATIONS]. + """ + + NONE = 0 + BASIC = 1 + FULL = 2 + + +def _get_environ_bool(name: str) -> bool: """Check an environment variable switch. Args: @@ -24,7 +41,7 @@ def get_environ_bool(name: str) -> bool: return has_environ -def get_environ_int(name: str, default: int) -> int: +def _get_environ_int(name: str, default: int) -> int: """Retrieves an integer environment variable. Args: @@ -44,7 +61,21 @@ def get_environ_int(name: str, default: int) -> int: return default -DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG") +def _get_textual_animations() -> AnimationsEnum: + """Get the value of the environment variable that controls textual animations. + + The variable can be in any of the values defined by [`AnimationsEnum`][textual.constants.AnimationsEnum]. + + Returns: + The value that the variable was set to. If the environment variable is set to an + invalid value, we default to showing all animations. + """ + value: str = get_environ("TEXTUAL_ANIMATIONS", "FULL") + enum_value = AnimationsEnum.__members__.get(value, AnimationsEnum.FULL) + return enum_value + + +DEBUG: Final[bool] = _get_environ_bool("TEXTUAL_DEBUG") """Enable debug mode.""" DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None) @@ -59,20 +90,26 @@ def get_environ_int(name: str, default: int) -> int: DEVTOOLS_HOST: Final[str] = get_environ("TEXTUAL_DEVTOOLS_HOST", "127.0.0.1") """The host where textual console is running.""" -DEVTOOLS_PORT: Final[int] = get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081) +DEVTOOLS_PORT: Final[int] = _get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081) """Constant with the port that the devtools will connect to.""" -SCREENSHOT_DELAY: Final[int] = get_environ_int("TEXTUAL_SCREENSHOT", -1) +SCREENSHOT_DELAY: Final[int] = _get_environ_int("TEXTUAL_SCREENSHOT", -1) """Seconds delay before taking screenshot.""" PRESS: Final[str] = get_environ("TEXTUAL_PRESS", "") """Keys to automatically press.""" -SHOW_RETURN: Final[bool] = get_environ_bool("TEXTUAL_SHOW_RETURN") +SHOW_RETURN: Final[bool] = _get_environ_bool("TEXTUAL_SHOW_RETURN") """Write the return value on exit.""" -MAX_FPS: Final[int] = get_environ_int("TEXTUAL_FPS", 60) +MAX_FPS: Final[int] = _get_environ_int("TEXTUAL_FPS", 60) """Maximum frames per second for updates.""" COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto") """Force color system override""" + +SHOW_ANIMATIONS: Final[AnimationsEnum] = _get_textual_animations() +"""Controls what animations Textual will display. + +This constant is defined by the environment variable `TEXTUAL_ANIMATIONS`. +""" From 136da2fae36bffa4791185aa21a1d33f536dd6f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 23 Jan 2024 13:51:36 +0000 Subject: [PATCH 02/13] Restrict when animations happen. Go over 'all' (as far as I can tell) animations in Textual. Most of them should only run when the env variable TEXTUAL_ANIMATIONS is set to FULL. A few animations may run on the level BASIC, which are animations that don't delay content appearing: - indeterminate progress bars - loading indicators - button presses - tab underlines - switch toggles - all (?) types of scrolling. These animations are completely disabled when the env var is NONE. The indeterminate progress bar displays a full, static bar and the loading indicator displays a string 'Loading...'. Many animation-related methods also grew a keyword parameter 'animate_on_level' that establishes the minimum level for said animation to take place. --- src/textual/_animator.py | 17 +++++- src/textual/app.py | 4 ++ src/textual/css/scalar_animation.py | 5 +- src/textual/css/styles.py | 6 ++ src/textual/scroll_view.py | 4 ++ src/textual/widget.py | 68 +++++++++++++++++++++++ src/textual/widgets/_button.py | 23 ++++++-- src/textual/widgets/_loading_indicator.py | 4 ++ src/textual/widgets/_progress_bar.py | 21 ++++--- src/textual/widgets/_switch.py | 8 ++- src/textual/widgets/_tabs.py | 25 +++++++-- 11 files changed, 164 insertions(+), 21 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 3561b1ba58..1cf16069de 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -12,6 +12,7 @@ from ._callback import invoke from ._easing import DEFAULT_EASING, EASING from ._types import CallbackType +from .constants import SHOW_ANIMATIONS, AnimationsEnum from .timer import Timer if TYPE_CHECKING: @@ -93,9 +94,11 @@ class SimpleAnimation(Animation): final_value: object easing: EasingFunction on_complete: CallbackType | None = None + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC + """Minimum level required for the animation to take place (inclusive).""" def __call__(self, time: float) -> bool: - if self.duration == 0: + if self.duration == 0 or self.animate_on_level.value > SHOW_ANIMATIONS.value: setattr(self.obj, self.attribute, self.final_value) return True @@ -170,6 +173,7 @@ def __call__( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.FULL, ) -> None: """Animate an attribute. @@ -182,6 +186,7 @@ def __call__( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ start_value = getattr(self._obj, attribute) if isinstance(value, str) and hasattr(start_value, "parse"): @@ -200,6 +205,7 @@ def __call__( delay=delay, easing=easing_function, on_complete=on_complete, + animate_on_level=animate_on_level, ) @@ -284,6 +290,7 @@ def animate( easing: EasingFunction | str = DEFAULT_EASING, delay: float = 0.0, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.FULL, ) -> None: """Animate an attribute to a new value. @@ -297,6 +304,7 @@ def animate( easing: An easing function. delay: Number of seconds to delay the start of the animation by. on_complete: Callback to run after the animation completes. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ animate_callback = partial( self._animate, @@ -308,6 +316,7 @@ def animate( speed=speed, easing=easing, on_complete=on_complete, + animate_on_level=animate_on_level, ) if delay: self._complete_event.clear() @@ -328,7 +337,8 @@ def _animate( speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, - ): + animate_on_level: AnimationsEnum = AnimationsEnum.FULL, + ) -> None: """Animate an attribute to a new value. Args: @@ -340,6 +350,7 @@ def _animate( speed: The speed of the animation. easing: An easing function. on_complete: Callback to run after the animation completes. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ if not hasattr(obj, attribute): raise AttributeError( @@ -373,6 +384,7 @@ def _animate( speed=speed, easing=easing_function, on_complete=on_complete, + animate_on_level=animate_on_level, ) if animation is None: @@ -410,6 +422,7 @@ def _animate( final_value=final_value, easing=easing_function, on_complete=on_complete, + animate_on_level=animate_on_level, ) assert animation is not None, "animation expected to be non-None" diff --git a/src/textual/app.py b/src/textual/app.py index 4f679c4787..b62bac0cab 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -72,6 +72,7 @@ from .await_remove import AwaitRemove from .binding import Binding, BindingType, _Bindings from .command import CommandPalette, Provider +from .constants import AnimationsEnum from .css.query import NoMatches from .css.stylesheet import RulesMap, Stylesheet from .design import ColorSystem @@ -670,6 +671,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.FULL, ) -> None: """Animate an attribute. @@ -684,6 +686,7 @@ def animate( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ self._animate( attribute, @@ -694,6 +697,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + animate_on_level=animate_on_level, ) async def stop_animation(self, attribute: str, complete: bool = True) -> None: diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index 018d28b191..67a46d1767 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -4,6 +4,7 @@ from .._animator import Animation, EasingFunction from .._types import CallbackType +from ..constants import SHOW_ANIMATIONS, AnimationsEnum from .scalar import Scalar, ScalarOffset if TYPE_CHECKING: @@ -23,6 +24,7 @@ def __init__( speed: float | None, easing: EasingFunction, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.FULL, ): assert ( speed is not None or duration is not None @@ -34,6 +36,7 @@ def __init__( self.final_value = value self.easing = easing self.on_complete = on_complete + self.animate_on_level = animate_on_level size = widget.outer_size viewport = widget.app.size @@ -52,7 +55,7 @@ def __call__(self, time: float) -> bool: factor = min(1.0, (time - self.start_time) / self.duration) eased_factor = self.easing(factor) - if eased_factor >= 1: + if eased_factor >= 1 or self.animate_on_level.value > SHOW_ANIMATIONS.value: setattr(self.styles, self.attribute, self.final_value) return True diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 5996918f38..9126a725d2 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -13,6 +13,7 @@ from .._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from .._types import CallbackType from ..color import Color +from ..constants import AnimationsEnum from ..geometry import Offset, Spacing from ._style_properties import ( AlignProperty, @@ -369,6 +370,7 @@ def __textual_animation__( speed: float | None, easing: EasingFunction, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.FULL, ) -> ScalarAnimation | None: if self.node is None: return None @@ -396,6 +398,7 @@ def __textual_animation__( speed=speed, easing=easing, on_complete=on_complete, + animate_on_level=animate_on_level, ) return None @@ -1138,6 +1141,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.FULL, ) -> None: """Animate an attribute. @@ -1150,6 +1154,7 @@ def animate( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ if self._animate is None: assert self.node is not None @@ -1164,6 +1169,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + animate_on_level=animate_on_level, ) def __rich_repr__(self) -> rich.repr.Result: diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index a4e3aa03d8..d8ba369cac 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -8,6 +8,7 @@ from ._animator import EasingFunction from ._types import CallbackType +from .constants import AnimationsEnum from .containers import ScrollableContainer from .geometry import Region, Size @@ -119,6 +120,7 @@ def scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -131,6 +133,7 @@ def scroll_to( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ self._scroll_to( @@ -142,6 +145,7 @@ def scroll_to( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def refresh_lines(self, y_start: int, line_count: int = 1) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index cbff95e1c3..cfa3a63c2d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -52,6 +52,7 @@ from .await_remove import AwaitRemove from .box_model import BoxModel from .cache import FIFOCache +from .constants import AnimationsEnum from .css.query import NoMatches, WrongType from .css.scalar import ScalarOffset from .dom import DOMNode, NoScreen @@ -1728,6 +1729,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.FULL, ) -> None: """Animate an attribute. @@ -1740,6 +1742,7 @@ def animate( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ if self._animate is None: self._animate = self.app.animator.bind(self) @@ -1753,6 +1756,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + animate_on_level=animate_on_level, ) async def stop_animation(self, attribute: str, complete: bool = True) -> None: @@ -1899,6 +1903,7 @@ def _scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> bool: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1911,6 +1916,7 @@ def _scroll_to( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). Returns: `True` if the scroll position changed, otherwise `False`. @@ -1938,6 +1944,7 @@ def _scroll_to( duration=duration, easing=easing, on_complete=on_complete, + animate_on_level=animate_on_level, ) scrolled_x = True if maybe_scroll_y: @@ -1951,6 +1958,7 @@ def _scroll_to( duration=duration, easing=easing, on_complete=on_complete, + animate_on_level=animate_on_level, ) scrolled_y = True @@ -1982,6 +1990,7 @@ def scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1994,6 +2003,7 @@ def scroll_to( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). Note: The call to scroll is made after the next refresh. @@ -2008,6 +2018,7 @@ def scroll_to( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_relative( @@ -2021,6 +2032,7 @@ def scroll_relative( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll relative to current position. @@ -2033,6 +2045,7 @@ def scroll_relative( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( None if x is None else (self.scroll_x + x), @@ -2043,6 +2056,7 @@ def scroll_relative( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_home( @@ -2054,6 +2068,7 @@ def scroll_home( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll to home position. @@ -2064,6 +2079,7 @@ def scroll_home( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 1.0 @@ -2076,6 +2092,7 @@ def scroll_home( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_end( @@ -2087,6 +2104,7 @@ def scroll_end( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll to the end of the container. @@ -2097,6 +2115,7 @@ def scroll_end( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 1.0 @@ -2118,6 +2137,7 @@ def _lazily_scroll_end() -> None: easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) self.call_after_refresh(_lazily_scroll_end) @@ -2131,6 +2151,7 @@ def scroll_left( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll one cell left. @@ -2141,6 +2162,7 @@ def scroll_left( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( x=self.scroll_target_x - 1, @@ -2150,6 +2172,7 @@ def scroll_left( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def _scroll_left_for_pointer( @@ -2161,6 +2184,7 @@ def _scroll_left_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> bool: """Scroll left one position, taking scroll sensitivity into account. @@ -2171,6 +2195,7 @@ def _scroll_left_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2187,6 +2212,7 @@ def _scroll_left_for_pointer( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_right( @@ -2198,6 +2224,7 @@ def scroll_right( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll one cell right. @@ -2208,6 +2235,7 @@ def scroll_right( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( x=self.scroll_target_x + 1, @@ -2217,6 +2245,7 @@ def scroll_right( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def _scroll_right_for_pointer( @@ -2228,6 +2257,7 @@ def _scroll_right_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> bool: """Scroll right one position, taking scroll sensitivity into account. @@ -2238,6 +2268,7 @@ def _scroll_right_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2254,6 +2285,7 @@ def _scroll_right_for_pointer( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_down( @@ -2265,6 +2297,7 @@ def scroll_down( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll one line down. @@ -2275,6 +2308,7 @@ def scroll_down( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_target_y + 1, @@ -2284,6 +2318,7 @@ def scroll_down( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def _scroll_down_for_pointer( @@ -2295,6 +2330,7 @@ def _scroll_down_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> bool: """Scroll down one position, taking scroll sensitivity into account. @@ -2305,6 +2341,7 @@ def _scroll_down_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2321,6 +2358,7 @@ def _scroll_down_for_pointer( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_up( @@ -2332,6 +2370,7 @@ def scroll_up( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll one line up. @@ -2342,6 +2381,7 @@ def scroll_up( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_target_y - 1, @@ -2351,6 +2391,7 @@ def scroll_up( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def _scroll_up_for_pointer( @@ -2362,6 +2403,7 @@ def _scroll_up_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> bool: """Scroll up one position, taking scroll sensitivity into account. @@ -2372,6 +2414,7 @@ def _scroll_up_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2388,6 +2431,7 @@ def _scroll_up_for_pointer( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_page_up( @@ -2399,6 +2443,7 @@ def scroll_page_up( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll one page up. @@ -2409,6 +2454,7 @@ def scroll_page_up( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y - self.container_size.height, @@ -2418,6 +2464,7 @@ def scroll_page_up( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_page_down( @@ -2429,6 +2476,7 @@ def scroll_page_down( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll one page down. @@ -2439,6 +2487,7 @@ def scroll_page_down( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y + self.container_size.height, @@ -2448,6 +2497,7 @@ def scroll_page_down( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_page_left( @@ -2459,6 +2509,7 @@ def scroll_page_left( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll one page left. @@ -2469,6 +2520,7 @@ def scroll_page_left( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 @@ -2480,6 +2532,7 @@ def scroll_page_left( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_page_right( @@ -2491,6 +2544,7 @@ def scroll_page_right( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll one page right. @@ -2501,6 +2555,7 @@ def scroll_page_right( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 @@ -2512,6 +2567,7 @@ def scroll_page_right( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_to_widget( @@ -2527,6 +2583,7 @@ def scroll_to_widget( origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> bool: """Scroll scrolling to bring a widget in to view. @@ -2540,6 +2597,7 @@ def scroll_to_widget( origin_visible: Ensure that the top left of the widget is within the window. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling has occurred in any descendant, otherwise `False`. @@ -2566,6 +2624,7 @@ def scroll_to_widget( origin_visible=origin_visible, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) if scroll_offset: scrolled = True @@ -2600,6 +2659,7 @@ def scroll_to_region( origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> Offset: """Scrolls a given region in to view, if required. @@ -2617,6 +2677,7 @@ def scroll_to_region( origin_visible: Ensure that the top left of the widget is within the window. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). Returns: The distance that was scrolled. @@ -2663,6 +2724,7 @@ def scroll_to_region( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) return delta @@ -2676,6 +2738,7 @@ def scroll_visible( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll the container to make this widget visible. @@ -2687,6 +2750,7 @@ def scroll_visible( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ parent = self.parent if isinstance(parent, Widget): @@ -2700,6 +2764,7 @@ def scroll_visible( easing=easing, force=force, on_complete=on_complete, + animate_on_level=animate_on_level, ) def scroll_to_center( @@ -2713,6 +2778,7 @@ def scroll_to_center( force: bool = False, origin_visible: bool = True, on_complete: CallbackType | None = None, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Scroll this widget to the center of self. @@ -2727,6 +2793,7 @@ def scroll_to_center( force: Force scrolling even when prohibited by overflow styling. origin_visible: Ensure that the top left corner of the widget remains visible after the scroll. on_complete: A callable to invoke when the animation is finished. + animate_on_level: Minimum level required for the animation to take place (inclusive). """ self.call_after_refresh( @@ -2740,6 +2807,7 @@ def scroll_to_center( center=True, origin_visible=origin_visible, on_complete=on_complete, + animate_on_level=animate_on_level, ) def can_view(self, widget: Widget) -> bool: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 83a3237b2d..909fcd1122 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -10,6 +10,7 @@ from .. import events from ..binding import Binding +from ..constants import SHOW_ANIMATIONS, AnimationsEnum from ..css._error_tools import friendly_list from ..message import Message from ..pad import HorizontalPad @@ -239,22 +240,34 @@ async def _on_click(self, event: events.Click) -> None: event.stop() self.press() - def press(self) -> Self: + def press(self, *, animate_on_level: AnimationsEnum = AnimationsEnum.BASIC) -> Self: """Respond to a button press. + Args: + animate_on_level: Minimum level required for the animation to take place (inclusive). + Returns: The button instance.""" if self.disabled or not self.display: return self # Manage the "active" effect: - self._start_active_affect() + self._start_active_affect(animate_on_level=animate_on_level) # ...and let other components know that we've just been clicked: self.post_message(Button.Pressed(self)) return self - def _start_active_affect(self) -> None: - """Start a small animation to show the button was clicked.""" - if self.active_effect_duration > 0: + def _start_active_affect( + self, *, animate_on_level: AnimationsEnum = AnimationsEnum.BASIC + ) -> None: + """Start a small animation to show the button was clicked. + + Args: + animate_on_level: Minimum level required for the animation to take place (inclusive). + """ + if ( + self.active_effect_duration > 0 + and SHOW_ANIMATIONS.value >= animate_on_level.value + ): self.add_class("-active") self.set_timer( self.active_effect_duration, partial(self.remove_class, "-active") diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py index e7cc4abb47..92799ca192 100644 --- a/src/textual/widgets/_loading_indicator.py +++ b/src/textual/widgets/_loading_indicator.py @@ -7,6 +7,7 @@ from rich.text import Text from ..color import Gradient +from ..constants import SHOW_ANIMATIONS, AnimationsEnum from ..events import Mount from ..widget import Widget @@ -54,6 +55,9 @@ def _on_mount(self, _: Mount) -> None: self.auto_refresh = 1 / 16 def render(self) -> RenderableType: + if SHOW_ANIMATIONS is AnimationsEnum.NONE: + return Text("Loading...") + elapsed = time() - self._start_time speed = 0.8 dot = "\u25cf" diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py index ec8c1b22cb..1c7e063f25 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -10,6 +10,7 @@ from .._types import UnusedParameter from ..app import ComposeResult, RenderResult +from ..constants import SHOW_ANIMATIONS, AnimationsEnum from ..containers import Horizontal from ..geometry import clamp from ..reactive import reactive @@ -105,14 +106,18 @@ def render_indeterminate(self) -> RenderResult: # Width used to enable the visual effect of the bar going into the corners. total_imaginary_width = width + highlighted_bar_width - speed = 30 # Cells per second. - # Compute the position of the bar. - start = (speed * self._get_elapsed_time()) % (2 * total_imaginary_width) - if start > total_imaginary_width: - # If the bar is to the right of its width, wrap it back from right to left. - start = 2 * total_imaginary_width - start # = (tiw - (start - tiw)) - start -= highlighted_bar_width - end = start + highlighted_bar_width + if SHOW_ANIMATIONS is AnimationsEnum.NONE: + start = 0 + end = width + else: + speed = 30 # Cells per second. + # Compute the position of the bar. + start = (speed * self._get_elapsed_time()) % (2 * total_imaginary_width) + if start > total_imaginary_width: + # If the bar is to the right of its width, wrap it back from right to left. + start = 2 * total_imaginary_width - start # = (tiw - (start - tiw)) + start -= highlighted_bar_width + end = start + highlighted_bar_width bar_style = self.get_component_rich_style("bar--indeterminate") return BarRenderable( diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index a6114ff3a8..405e9c4161 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -5,6 +5,7 @@ from rich.console import RenderableType from ..binding import Binding, BindingType +from ..constants import AnimationsEnum from ..events import Click from ..geometry import Size from ..message import Message @@ -130,7 +131,12 @@ def __init__( def watch_value(self, value: bool) -> None: target_slider_pos = 1.0 if value else 0.0 if self._should_animate: - self.animate("slider_pos", target_slider_pos, duration=0.3) + self.animate( + "slider_pos", + target_slider_pos, + duration=0.3, + animate_on_level=AnimationsEnum.BASIC, + ) else: self.slider_pos = target_slider_pos self.post_message(self.Changed(self, self.value)) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 0123e17688..6442696036 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -13,6 +13,7 @@ from ..app import ComposeResult, RenderResult from ..await_complete import AwaitComplete from ..binding import Binding, BindingType +from ..constants import SHOW_ANIMATIONS, AnimationsEnum from ..containers import Container, Horizontal, Vertical from ..css.query import NoMatches from ..events import Mount @@ -590,11 +591,17 @@ def watch_active(self, previously_active: str, active: str) -> None: underline.highlight_end = 0 self.post_message(self.Cleared(self)) - def _highlight_active(self, animate: bool = True) -> None: + def _highlight_active( + self, + animate: bool = True, + *, + animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + ) -> None: """Move the underline bar to under the active tab. Args: animate: Should the bar animate? + animate_on_level: Minimum level required for the animation to take place (inclusive). """ underline = self.query_one(Underline) try: @@ -607,7 +614,7 @@ def _highlight_active(self, animate: bool = True) -> None: underline.show_highlight = True tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter) start, end = tab_region.column_span - if animate: + if animate and SHOW_ANIMATIONS.value >= animate_on_level.value: def animate_underline() -> None: """Animate the underline.""" @@ -620,8 +627,18 @@ def animate_underline() -> None: active_tab.styles.gutter ) start, end = tab_region.column_span - underline.animate("highlight_start", start, duration=0.3) - underline.animate("highlight_end", end, duration=0.3) + underline.animate( + "highlight_start", + start, + duration=0.3, + animate_on_level=animate_on_level, + ) + underline.animate( + "highlight_end", + end, + duration=0.3, + animate_on_level=animate_on_level, + ) self.set_timer(0.02, lambda: self.call_after_refresh(animate_underline)) else: From fda29ea4321f299cb05bfbe1249979ea043e48c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 25 Jan 2024 10:58:44 +0000 Subject: [PATCH 03/13] Use 'SHOW_ANIMATIONS' from original namespace. By using 'constants.SHOW_ANIMATIONS' instead of importing the constant directly we make it easier to patch for testing. See: https://mathspp.com/blog/til/patching-module-globals-with-pytest --- src/textual/_animator.py | 9 ++++++--- src/textual/css/scalar_animation.py | 8 ++++++-- src/textual/widgets/_button.py | 6 +++--- src/textual/widgets/_loading_indicator.py | 5 +++-- src/textual/widgets/_progress_bar.py | 5 +++-- src/textual/widgets/_tabs.py | 6 +++--- 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 1cf16069de..08d7096e2c 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -8,11 +8,11 @@ from typing_extensions import Protocol, runtime_checkable -from . import _time +from . import _time, constants from ._callback import invoke from ._easing import DEFAULT_EASING, EASING from ._types import CallbackType -from .constants import SHOW_ANIMATIONS, AnimationsEnum +from .constants import AnimationsEnum from .timer import Timer if TYPE_CHECKING: @@ -98,7 +98,10 @@ class SimpleAnimation(Animation): """Minimum level required for the animation to take place (inclusive).""" def __call__(self, time: float) -> bool: - if self.duration == 0 or self.animate_on_level.value > SHOW_ANIMATIONS.value: + if ( + self.duration == 0 + or self.animate_on_level.value > constants.SHOW_ANIMATIONS.value + ): setattr(self.obj, self.attribute, self.final_value) return True diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index 67a46d1767..35dc154ef3 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -2,9 +2,10 @@ from typing import TYPE_CHECKING +from .. import constants from .._animator import Animation, EasingFunction from .._types import CallbackType -from ..constants import SHOW_ANIMATIONS, AnimationsEnum +from ..constants import AnimationsEnum from .scalar import Scalar, ScalarOffset if TYPE_CHECKING: @@ -55,7 +56,10 @@ def __call__(self, time: float) -> bool: factor = min(1.0, (time - self.start_time) / self.duration) eased_factor = self.easing(factor) - if eased_factor >= 1 or self.animate_on_level.value > SHOW_ANIMATIONS.value: + if ( + eased_factor >= 1 + or self.animate_on_level.value > constants.SHOW_ANIMATIONS.value + ): setattr(self.styles, self.attribute, self.final_value) return True diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 909fcd1122..45443bb5cd 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -8,9 +8,9 @@ from rich.text import Text, TextType from typing_extensions import Literal, Self -from .. import events +from .. import constants, events from ..binding import Binding -from ..constants import SHOW_ANIMATIONS, AnimationsEnum +from ..constants import AnimationsEnum from ..css._error_tools import friendly_list from ..message import Message from ..pad import HorizontalPad @@ -266,7 +266,7 @@ def _start_active_affect( """ if ( self.active_effect_duration > 0 - and SHOW_ANIMATIONS.value >= animate_on_level.value + and constants.SHOW_ANIMATIONS.value >= animate_on_level.value ): self.add_class("-active") self.set_timer( diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py index 92799ca192..06a722588a 100644 --- a/src/textual/widgets/_loading_indicator.py +++ b/src/textual/widgets/_loading_indicator.py @@ -6,8 +6,9 @@ from rich.style import Style from rich.text import Text +from .. import constants from ..color import Gradient -from ..constants import SHOW_ANIMATIONS, AnimationsEnum +from ..constants import AnimationsEnum from ..events import Mount from ..widget import Widget @@ -55,7 +56,7 @@ def _on_mount(self, _: Mount) -> None: self.auto_refresh = 1 / 16 def render(self) -> RenderableType: - if SHOW_ANIMATIONS is AnimationsEnum.NONE: + if constants.SHOW_ANIMATIONS is AnimationsEnum.NONE: return Text("Loading...") elapsed = time() - self._start_time diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py index 1c7e063f25..04788f7dd5 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -8,9 +8,10 @@ from rich.style import Style +from .. import constants from .._types import UnusedParameter from ..app import ComposeResult, RenderResult -from ..constants import SHOW_ANIMATIONS, AnimationsEnum +from ..constants import AnimationsEnum from ..containers import Horizontal from ..geometry import clamp from ..reactive import reactive @@ -106,7 +107,7 @@ def render_indeterminate(self) -> RenderResult: # Width used to enable the visual effect of the bar going into the corners. total_imaginary_width = width + highlighted_bar_width - if SHOW_ANIMATIONS is AnimationsEnum.NONE: + if constants.SHOW_ANIMATIONS is AnimationsEnum.NONE: start = 0 end = width else: diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 6442696036..6ffd017e64 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -9,11 +9,11 @@ from rich.style import Style from rich.text import Text, TextType -from .. import events +from .. import constants, events from ..app import ComposeResult, RenderResult from ..await_complete import AwaitComplete from ..binding import Binding, BindingType -from ..constants import SHOW_ANIMATIONS, AnimationsEnum +from ..constants import AnimationsEnum from ..containers import Container, Horizontal, Vertical from ..css.query import NoMatches from ..events import Mount @@ -614,7 +614,7 @@ def _highlight_active( underline.show_highlight = True tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter) start, end = tab_region.column_span - if animate and SHOW_ANIMATIONS.value >= animate_on_level.value: + if animate and constants.SHOW_ANIMATIONS.value >= animate_on_level.value: def animate_underline() -> None: """Animate the underline.""" From 37e766890262be65e452c7a889ad8e3c3c7cdc79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 25 Jan 2024 11:51:40 +0000 Subject: [PATCH 04/13] Add 'App.show_animations'. The original issue (#3992) asked for a property on 'App' that allows controlling whether that app's animations should be played or not. --- CHANGELOG.md | 3 ++- src/textual/_animator.py | 10 ++++++++-- src/textual/_context.py | 6 ++++-- src/textual/app.py | 6 ++++++ src/textual/constants.py | 8 +------- src/textual/css/scalar_animation.py | 11 +++++++++-- src/textual/widgets/_button.py | 4 ++-- src/textual/widgets/_loading_indicator.py | 3 +-- src/textual/widgets/_progress_bar.py | 3 +-- src/textual/widgets/_tabs.py | 4 ++-- 10 files changed, 36 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc97802e5..1372b68db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `DOMNode.has_pseudo_classes` https://github.com/Textualize/textual/pull/3970 - Added `Widget.allow_focus` and `Widget.allow_focus_children` https://github.com/Textualize/textual/pull/3989 -- Added support for environment variable `TEXTUAL_ANIMATIONS` to control what animations Textual displays https://github.com/Textualize/textual/issues/3992 +- Added support for environment variable `TEXTUAL_ANIMATIONS` to control what animations Textual displays https://github.com/Textualize/textual/pull/4062 +- Add attribute `App.show_animations` to control whether animations on that app run or not https://github.com/Textualize/textual/pull/4062 ### Fixed diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 08d7096e2c..09e0a16d5d 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -8,8 +8,9 @@ from typing_extensions import Protocol, runtime_checkable -from . import _time, constants +from . import _time from ._callback import invoke +from ._context import active_app from ._easing import DEFAULT_EASING, EASING from ._types import CallbackType from .constants import AnimationsEnum @@ -97,10 +98,15 @@ class SimpleAnimation(Animation): animate_on_level: AnimationsEnum = AnimationsEnum.BASIC """Minimum level required for the animation to take place (inclusive).""" + def __post_init__(self) -> None: + """Cache baseline that determines whether an animation runs.""" + # We cache this value so we don't have to `.get` the context var repeatedly. + self.show_animations = active_app.get().show_animations + def __call__(self, time: float) -> bool: if ( self.duration == 0 - or self.animate_on_level.value > constants.SHOW_ANIMATIONS.value + or self.animate_on_level.value > self.show_animations.value ): setattr(self.obj, self.attribute, self.final_value) return True diff --git a/src/textual/_context.py b/src/textual/_context.py index 33d8369d49..b1b20b4d29 100644 --- a/src/textual/_context.py +++ b/src/textual/_context.py @@ -14,12 +14,14 @@ class NoActiveAppError(RuntimeError): """Runtime error raised if we try to retrieve the active app when there is none.""" -active_app: ContextVar["App"] = ContextVar("active_app") +active_app: ContextVar["App[object]"] = ContextVar("active_app") active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump") prevent_message_types_stack: ContextVar[list[set[type[Message]]]] = ContextVar( "prevent_message_types_stack" ) -visible_screen_stack: ContextVar[list[Screen]] = ContextVar("visible_screen_stack") +visible_screen_stack: ContextVar[list[Screen[object]]] = ContextVar( + "visible_screen_stack" +) """A stack of visible screens (with background alpha < 1), used in the screen render process.""" message_hook: ContextVar[Callable[[Message], None]] = ContextVar("message_hook") """A callable that accepts a message. Used by App.run_test.""" diff --git a/src/textual/app.py b/src/textual/app.py index b62bac0cab..b18940071b 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -576,6 +576,12 @@ def __init__( self.set_class(self.dark, "-dark-mode") self.set_class(not self.dark, "-light-mode") + self.show_animations: AnimationsEnum = constants._get_textual_animations() + """Determines what type of animations the app will display. + + See [`textual.constants.SHOW_ANIMATIONS`][textual.constants.SHOW_ANIMATIONS]. + """ + def validate_title(self, title: Any) -> str: """Make sure the title is set to a string.""" return str(title) diff --git a/src/textual/constants.py b/src/textual/constants.py index e651986fa2..9d219d33ae 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -20,7 +20,7 @@ class AnimationsEnum(enum.Enum): (e.g., scrolling). - `FULL` displays all animations. - See also: [`SHOW_ANIMATIONS`][textual.constants.SHOW_ANIMATIONS]. + See also: [`App.show_animations`][textual.app.App.show_animations]. """ NONE = 0 @@ -107,9 +107,3 @@ def _get_textual_animations() -> AnimationsEnum: COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto") """Force color system override""" - -SHOW_ANIMATIONS: Final[AnimationsEnum] = _get_textual_animations() -"""Controls what animations Textual will display. - -This constant is defined by the environment variable `TEXTUAL_ANIMATIONS`. -""" diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index 35dc154ef3..dbb17d6f1e 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -2,8 +2,8 @@ from typing import TYPE_CHECKING -from .. import constants from .._animator import Animation, EasingFunction +from .._context import active_app from .._types import CallbackType from ..constants import AnimationsEnum from .scalar import Scalar, ScalarOffset @@ -52,13 +52,20 @@ def __init__( assert duration is not None, "Duration expected to be non-None" self.duration = duration + self.show_animations = active_app.get().show_animations + """Cached version of [`App.show_animations`][textual.app.App.show_animations]. + + Caching this avoids repeatedly getting the context variable when the animation + is supposed to play. + """ + def __call__(self, time: float) -> bool: factor = min(1.0, (time - self.start_time) / self.duration) eased_factor = self.easing(factor) if ( eased_factor >= 1 - or self.animate_on_level.value > constants.SHOW_ANIMATIONS.value + or self.animate_on_level.value > self.show_animations.value ): setattr(self.styles, self.attribute, self.final_value) return True diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 45443bb5cd..8e0c2526f6 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -8,7 +8,7 @@ from rich.text import Text, TextType from typing_extensions import Literal, Self -from .. import constants, events +from .. import events from ..binding import Binding from ..constants import AnimationsEnum from ..css._error_tools import friendly_list @@ -266,7 +266,7 @@ def _start_active_affect( """ if ( self.active_effect_duration > 0 - and constants.SHOW_ANIMATIONS.value >= animate_on_level.value + and self.app.show_animations.value >= animate_on_level.value ): self.add_class("-active") self.set_timer( diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py index 06a722588a..2113740676 100644 --- a/src/textual/widgets/_loading_indicator.py +++ b/src/textual/widgets/_loading_indicator.py @@ -6,7 +6,6 @@ from rich.style import Style from rich.text import Text -from .. import constants from ..color import Gradient from ..constants import AnimationsEnum from ..events import Mount @@ -56,7 +55,7 @@ def _on_mount(self, _: Mount) -> None: self.auto_refresh = 1 / 16 def render(self) -> RenderableType: - if constants.SHOW_ANIMATIONS is AnimationsEnum.NONE: + if self.app.show_animations is AnimationsEnum.NONE: return Text("Loading...") elapsed = time() - self._start_time diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py index 04788f7dd5..eff3f9d1ec 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -8,7 +8,6 @@ from rich.style import Style -from .. import constants from .._types import UnusedParameter from ..app import ComposeResult, RenderResult from ..constants import AnimationsEnum @@ -107,7 +106,7 @@ def render_indeterminate(self) -> RenderResult: # Width used to enable the visual effect of the bar going into the corners. total_imaginary_width = width + highlighted_bar_width - if constants.SHOW_ANIMATIONS is AnimationsEnum.NONE: + if self.app.show_animations is AnimationsEnum.NONE: start = 0 end = width else: diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 6ffd017e64..b8bd9acc73 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -9,7 +9,7 @@ from rich.style import Style from rich.text import Text, TextType -from .. import constants, events +from .. import events from ..app import ComposeResult, RenderResult from ..await_complete import AwaitComplete from ..binding import Binding, BindingType @@ -614,7 +614,7 @@ def _highlight_active( underline.show_highlight = True tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter) start, end = tab_region.column_span - if animate and constants.SHOW_ANIMATIONS.value >= animate_on_level.value: + if animate and self.app.show_animations.value >= animate_on_level.value: def animate_underline() -> None: """Animate the underline.""" From 98d4fd2107dd1d21982659a44d7caeea143e4c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 25 Jan 2024 14:11:51 +0000 Subject: [PATCH 05/13] Add TEXTUAL_ANIMATIONS tests. --- tests/animations/test_environment_variable.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/animations/test_environment_variable.py diff --git a/tests/animations/test_environment_variable.py b/tests/animations/test_environment_variable.py new file mode 100644 index 0000000000..727b4ce00c --- /dev/null +++ b/tests/animations/test_environment_variable.py @@ -0,0 +1,37 @@ +import pytest + +from textual.app import App +from textual.constants import AnimationsEnum, _get_textual_animations + + +@pytest.mark.parametrize( + ["env_variable", "value"], + [ + ("", AnimationsEnum.FULL), # default + ("FULL", AnimationsEnum.FULL), + ("BASIC", AnimationsEnum.BASIC), + ("NONE", AnimationsEnum.NONE), + ("garbanzo beans", AnimationsEnum.FULL), # fallback + ], +) +def test__get_textual_animations(monkeypatch, env_variable, value): # type: ignore + """Test that we parse the correct values from the env variable.""" + monkeypatch.setenv("TEXTUAL_ANIMATIONS", env_variable) + assert _get_textual_animations() == value + + +@pytest.mark.parametrize( + ["env_variable", "value"], + [ + ("", AnimationsEnum.FULL), # default + ("FULL", AnimationsEnum.FULL), + ("BASIC", AnimationsEnum.BASIC), + ("NONE", AnimationsEnum.NONE), + ("garbanzo beans", AnimationsEnum.FULL), # fallback + ], +) +def test_app_show_animations(monkeypatch, env_variable, value): # type: ignore + """Test that the app gets the value of `show_animations` correctly.""" + monkeypatch.setenv("TEXTUAL_ANIMATIONS", env_variable) + app = App() + assert app.show_animations == value From ef55ab363b816e24adf3b8d99356144a944db925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 25 Jan 2024 16:09:37 +0000 Subject: [PATCH 06/13] Add tests for 'basic' animations. Check that animations that should happen on the BASIC level do happen at that level and don't happen on the NONE level. --- tests/animations/test_button_animation.py | 43 ++++++++++++++ .../test_loading_indicator_animation.py | 44 ++++++++++++++ .../animations/test_progress_bar_animation.py | 48 +++++++++++++++ tests/animations/test_scrolling_animation.py | 58 +++++++++++++++++++ tests/animations/test_switch_animation.py | 49 ++++++++++++++++ .../test_tabs_underline_animation.py | 52 +++++++++++++++++ 6 files changed, 294 insertions(+) create mode 100644 tests/animations/test_button_animation.py create mode 100644 tests/animations/test_loading_indicator_animation.py create mode 100644 tests/animations/test_progress_bar_animation.py create mode 100644 tests/animations/test_scrolling_animation.py create mode 100644 tests/animations/test_switch_animation.py create mode 100644 tests/animations/test_tabs_underline_animation.py diff --git a/tests/animations/test_button_animation.py b/tests/animations/test_button_animation.py new file mode 100644 index 0000000000..3a7b5e3444 --- /dev/null +++ b/tests/animations/test_button_animation.py @@ -0,0 +1,43 @@ +""" +Tests for the “button pressed” animation, which is considered a basic animation. +(An animation that also plays on the level BASIC.) +""" + +from textual.app import App, ComposeResult +from textual.constants import AnimationsEnum +from textual.widgets import Button + + +class ButtonApp(App[None]): + def compose(self) -> ComposeResult: + yield Button() + + +async def test_button_animates_on_full() -> None: + """The button click animation should play on FULL.""" + app = ButtonApp() + app.show_animations = AnimationsEnum.FULL + + async with app.run_test() as pilot: + await pilot.click(Button) + assert app.query_one(Button).has_class("-active") + + +async def test_button_animates_on_basic() -> None: + """The button click animation should play on BASIC.""" + app = ButtonApp() + app.show_animations = AnimationsEnum.BASIC + + async with app.run_test() as pilot: + await pilot.click(Button) + assert app.query_one(Button).has_class("-active") + + +async def test_button_does_not_animate_on_none() -> None: + """The button click animation should play on NONE.""" + app = ButtonApp() + app.show_animations = AnimationsEnum.NONE + + async with app.run_test() as pilot: + await pilot.click(Button) + assert not app.query_one(Button).has_class("-active") diff --git a/tests/animations/test_loading_indicator_animation.py b/tests/animations/test_loading_indicator_animation.py new file mode 100644 index 0000000000..5c02079ec7 --- /dev/null +++ b/tests/animations/test_loading_indicator_animation.py @@ -0,0 +1,44 @@ +""" +Tests for the loading indicator animation, which is considered a basic animation. +(An animation that also plays on the level BASIC.) +""" + +from textual.app import App +from textual.constants import AnimationsEnum +from textual.widgets import LoadingIndicator + + +async def test_loading_indicator_is_not_static_on_full() -> None: + """The loading indicator doesn't fall back to the static render on FULL.""" + app = App() + app.show_animations = AnimationsEnum.FULL + + async with app.run_test() as pilot: + app.screen.loading = True + await pilot.pause() + indicator = app.query_one(LoadingIndicator) + assert str(indicator.render()) != "Loading..." + + +async def test_loading_indicator_is_not_static_on_basic() -> None: + """The loading indicator doesn't fall back to the static render on BASIC.""" + app = App() + app.show_animations = AnimationsEnum.BASIC + + async with app.run_test() as pilot: + app.screen.loading = True + await pilot.pause() + indicator = app.query_one(LoadingIndicator) + assert str(indicator.render()) != "Loading..." + + +async def test_loading_indicator_is_static_on_none() -> None: + """The loading indicator falls back to the static render on NONE.""" + app = App() + app.show_animations = AnimationsEnum.NONE + + async with app.run_test() as pilot: + app.screen.loading = True + await pilot.pause() + indicator = app.query_one(LoadingIndicator) + assert str(indicator.render()) == "Loading..." diff --git a/tests/animations/test_progress_bar_animation.py b/tests/animations/test_progress_bar_animation.py new file mode 100644 index 0000000000..a677177c4b --- /dev/null +++ b/tests/animations/test_progress_bar_animation.py @@ -0,0 +1,48 @@ +""" +Tests for the indeterminate progress bar animation, which is considered a basic +animation. (An animation that also plays on the level BASIC.) +""" + +from textual.app import App, ComposeResult +from textual.constants import AnimationsEnum +from textual.widgets import ProgressBar +from textual.widgets._progress_bar import Bar + + +class ProgressBarApp(App[None]): + def compose(self) -> ComposeResult: + yield ProgressBar() + + +async def test_progress_bar_animates_on_full() -> None: + """An indeterminate progress bar is not fully highlighted when animating.""" + app = ProgressBarApp() + app.show_animations = AnimationsEnum.FULL + + async with app.run_test(): + bar_renderable = app.query_one(Bar).render() + start, end = bar_renderable.highlight_range + assert start != 0 or end != app.query_one(Bar).size.width + + +async def test_progress_bar_animates_on_basic() -> None: + """An indeterminate progress bar is not fully highlighted when animating.""" + app = ProgressBarApp() + app.show_animations = AnimationsEnum.BASIC + + async with app.run_test(): + bar_renderable = app.query_one(Bar).render() + start, end = bar_renderable.highlight_range + assert start != 0 or end != app.query_one(Bar).size.width + + +async def test_progress_bar_does_not_animate_on_none() -> None: + """An indeterminate progress bar is fully highlighted when not animating.""" + app = ProgressBarApp() + app.show_animations = AnimationsEnum.NONE + + async with app.run_test(): + bar_renderable = app.query_one(Bar).render() + start, end = bar_renderable.highlight_range + assert start == 0 + assert end == app.query_one(Bar).size.width diff --git a/tests/animations/test_scrolling_animation.py b/tests/animations/test_scrolling_animation.py new file mode 100644 index 0000000000..3b13a873bf --- /dev/null +++ b/tests/animations/test_scrolling_animation.py @@ -0,0 +1,58 @@ +""" +Tests for scrolling animations, which are considered a basic animation. +(An animation that also plays on the level BASIC.) +""" + +from textual.app import App, ComposeResult +from textual.constants import AnimationsEnum +from textual.containers import VerticalScroll +from textual.widgets import Label + + +class TallApp(App[None]): + def compose(self) -> ComposeResult: + with VerticalScroll(): + for _ in range(100): + yield Label() + + +async def test_scrolling_animates_on_full() -> None: + app = TallApp() + app.show_animations = AnimationsEnum.FULL + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + current_scroll = vertical_scroll.scroll_offset + # A ridiculously long duration means that in the fraction of a second + # we take to check the scroll position again we haven't moved yet. + vertical_scroll.scroll_end(duration=10000) + await pilot.pause() + assert vertical_scroll.scroll_offset == current_scroll + + +async def test_scrolling_animates_on_basic() -> None: + app = TallApp() + app.show_animations = AnimationsEnum.BASIC + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + current_scroll = vertical_scroll.scroll_offset + # A ridiculously long duration means that in the fraction of a second + # we take to check the scroll position again we haven't moved yet. + vertical_scroll.scroll_end(duration=10000) + await pilot.pause() + assert vertical_scroll.scroll_offset == current_scroll + + +async def test_scrolling_does_not_animate_on_none() -> None: + app = TallApp() + app.show_animations = AnimationsEnum.NONE + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + current_scroll = vertical_scroll.scroll_offset + # Even with a supposedly really long scroll animation duration, + # we jump to the end because we're not animating. + vertical_scroll.scroll_end(duration=10000) + await pilot.pause() + assert vertical_scroll.scroll_offset != current_scroll diff --git a/tests/animations/test_switch_animation.py b/tests/animations/test_switch_animation.py new file mode 100644 index 0000000000..1fab682bbf --- /dev/null +++ b/tests/animations/test_switch_animation.py @@ -0,0 +1,49 @@ +""" +Tests for the switch toggle animation, which is considered a basic animation. +(An animation that also plays on the level BASIC.) +""" + +from textual.app import App, ComposeResult +from textual.constants import AnimationsEnum +from textual.widgets import Switch + + +class SwitchApp(App[None]): + def compose(self) -> ComposeResult: + yield Switch() + + +async def test_switch_animates_on_full() -> None: + app = SwitchApp() + app.show_animations = AnimationsEnum.FULL + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + before = switch.slider_pos + switch.action_toggle() + await pilot.pause() + assert abs(switch.slider_pos - before) < 1 + + +async def test_switch_animates_on_basic() -> None: + app = SwitchApp() + app.show_animations = AnimationsEnum.BASIC + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + before = switch.slider_pos + switch.action_toggle() + await pilot.pause() + assert abs(switch.slider_pos - before) < 1 + + +async def test_switch_does_not_animate_on_none() -> None: + app = SwitchApp() + app.show_animations = AnimationsEnum.NONE + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + before = switch.slider_pos + switch.action_toggle() + await pilot.pause() + assert abs(switch.slider_pos - before) == 1 diff --git a/tests/animations/test_tabs_underline_animation.py b/tests/animations/test_tabs_underline_animation.py new file mode 100644 index 0000000000..c94a317219 --- /dev/null +++ b/tests/animations/test_tabs_underline_animation.py @@ -0,0 +1,52 @@ +""" +Tests for the tabs underline animation, which is considered a basic animation. +(An animation that also plays on the level BASIC.) +""" + +from textual.app import App, ComposeResult +from textual.constants import AnimationsEnum +from textual.widgets import Label, TabbedContent, Tabs +from textual.widgets._tabs import Underline + + +class TabbedContentApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + for _ in range(10): + yield Label("Hey!") + + +async def test_tabs_underline_animates_on_full() -> None: + """The underline takes some time to move when animated.""" + app = TabbedContentApp() + app.show_animations = AnimationsEnum.FULL + + async with app.run_test(): + underline = app.query_one(Underline) + before = underline._highlight_range + app.query_one(Tabs).action_previous_tab() + assert before == underline._highlight_range + + +async def test_tabs_underline_animates_on_basic() -> None: + """The underline takes some time to move when animated.""" + app = TabbedContentApp() + app.show_animations = AnimationsEnum.BASIC + + async with app.run_test(): + underline = app.query_one(Underline) + before = underline._highlight_range + app.query_one(Tabs).action_previous_tab() + assert before == underline._highlight_range + + +async def test_tabs_underline_does_not_animate_on_none() -> None: + """The underline jumps to its final position when not animated.""" + app = TabbedContentApp() + app.show_animations = AnimationsEnum.NONE + + async with app.run_test(): + underline = app.query_one(Underline) + before = underline._highlight_range + app.query_one(Tabs).action_previous_tab() + assert before != underline._highlight_range From 023bb3378b8c9588733748494812fb564386d5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 25 Jan 2024 17:50:40 +0000 Subject: [PATCH 07/13] Test disabling generic animations. --- tests/animations/test_disabling_animations.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/animations/test_disabling_animations.py diff --git a/tests/animations/test_disabling_animations.py b/tests/animations/test_disabling_animations.py new file mode 100644 index 0000000000..4e20fa3a90 --- /dev/null +++ b/tests/animations/test_disabling_animations.py @@ -0,0 +1,123 @@ +""" +Test that generic animations can be disabled. +""" + +from textual.app import App, ComposeResult +from textual.color import Color +from textual.constants import AnimationsEnum +from textual.widgets import Label + + +class SingleLabelApp(App[None]): + """Single label whose background colour we'll animate.""" + + CSS = """ + Label { + background: red; + } + """ + + def compose(self) -> ComposeResult: + yield Label() + + +async def test_style_animations_via_animate_work_on_full() -> None: + app = SingleLabelApp() + app.show_animations = AnimationsEnum.FULL + + async with app.run_test() as pilot: + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + # Test. + label.styles.animate("background", "blue", duration=1) + await pilot.pause() + assert label.styles.background != Color.parse("blue") + + +async def test_style_animations_via_animate_are_disabled_on_basic() -> None: + app = SingleLabelApp() + app.show_animations = AnimationsEnum.BASIC + + async with app.run_test() as pilot: + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + # Test. + label.styles.animate("background", "blue", duration=1) + await pilot.pause() + assert label.styles.background == Color.parse("blue") + + +async def test_style_animations_via_animate_are_disabled_on_none() -> None: + app = SingleLabelApp() + app.show_animations = AnimationsEnum.NONE + + async with app.run_test() as pilot: + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + # Test. + label.styles.animate("background", "blue", duration=1) + await pilot.pause() + assert label.styles.background == Color.parse("blue") + + +class LabelWithTransitionsApp(App[None]): + """Single label whose background is set to animate with TCSS.""" + + CSS = """ + Label { + background: red; + transition: background 1s; + } + + Label.blue-bg { + background: blue; + } + """ + + def compose(self) -> ComposeResult: + yield Label() + + +async def test_style_animations_via_transition_work_on_full() -> None: + app = LabelWithTransitionsApp() + app.show_animations = AnimationsEnum.FULL + + async with app.run_test() as pilot: + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + # Test. + label.add_class("blue-bg") + await pilot.pause() + assert label.styles.background != Color.parse("blue") + + +async def test_style_animations_via_transition_are_disabled_on_basic() -> None: + app = LabelWithTransitionsApp() + app.show_animations = AnimationsEnum.BASIC + + async with app.run_test() as pilot: + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + # Test. + label.add_class("blue-bg") + await pilot.pause() + assert label.styles.background == Color.parse("blue") + + +async def test_style_animations_via_transition_are_disabled_on_none() -> None: + app = LabelWithTransitionsApp() + app.show_animations = AnimationsEnum.NONE + + async with app.run_test() as pilot: + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + # Test. + label.add_class("blue-bg") + await pilot.pause() + assert label.styles.background == Color.parse("blue") From 89844e83429ecdb62278e6ac40b4dbb098588a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 25 Jan 2024 18:34:23 +0000 Subject: [PATCH 08/13] Make switch tests more robust. --- tests/animations/test_switch_animation.py | 30 ++++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/animations/test_switch_animation.py b/tests/animations/test_switch_animation.py index 1fab682bbf..18ee1e9963 100644 --- a/tests/animations/test_switch_animation.py +++ b/tests/animations/test_switch_animation.py @@ -17,33 +17,39 @@ async def test_switch_animates_on_full() -> None: app = SwitchApp() app.show_animations = AnimationsEnum.FULL - async with app.run_test() as pilot: + async with app.run_test(): switch = app.query_one(Switch) - before = switch.slider_pos switch.action_toggle() - await pilot.pause() - assert abs(switch.slider_pos - before) < 1 + animator = app.animator + # Move to the next animation frame. + await animator() + # The animation should still be running. + assert app.animator.is_being_animated(switch, "slider_pos") async def test_switch_animates_on_basic() -> None: app = SwitchApp() app.show_animations = AnimationsEnum.BASIC - async with app.run_test() as pilot: + async with app.run_test(): switch = app.query_one(Switch) - before = switch.slider_pos switch.action_toggle() - await pilot.pause() - assert abs(switch.slider_pos - before) < 1 + animator = app.animator + # Move to the next animation frame. + await animator() + # The animation should still be running. + assert app.animator.is_being_animated(switch, "slider_pos") async def test_switch_does_not_animate_on_none() -> None: app = SwitchApp() app.show_animations = AnimationsEnum.NONE - async with app.run_test() as pilot: + async with app.run_test(): switch = app.query_one(Switch) - before = switch.slider_pos switch.action_toggle() - await pilot.pause() - assert abs(switch.slider_pos - before) == 1 + animator = app.animator + # Let the animator handle pending animations. + await animator() + # The animation should be done. + assert not app.animator.is_being_animated(switch, "slider_pos") From 66f3ec657981bb29c7f4d85959d4ec2fbb3f5644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 31 Jan 2024 15:47:44 +0000 Subject: [PATCH 09/13] Address review feedback. --- CHANGELOG.md | 2 +- src/textual/_animator.py | 47 +++--- src/textual/app.py | 12 +- src/textual/constants.py | 45 +++--- src/textual/css/scalar_animation.py | 22 ++- src/textual/css/styles.py | 12 +- src/textual/scroll_view.py | 8 +- src/textual/widget.py | 136 +++++++++--------- src/textual/widgets/_button.py | 19 +-- src/textual/widgets/_loading_indicator.py | 3 +- src/textual/widgets/_progress_bar.py | 3 +- src/textual/widgets/_switch.py | 3 +- src/textual/widgets/_tabs.py | 11 +- tests/animations/test_button_animation.py | 7 +- tests/animations/test_disabling_animations.py | 34 ++++- tests/animations/test_environment_variable.py | 29 ++-- .../test_loading_indicator_animation.py | 7 +- .../animations/test_progress_bar_animation.py | 7 +- tests/animations/test_scrolling_animation.py | 43 +++--- tests/animations/test_switch_animation.py | 42 ++++-- .../test_tabs_underline_animation.py | 49 +++++-- 21 files changed, 296 insertions(+), 245 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5989ecb9b1..c8de79b038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `MessagePump.message_queue_size` https://github.com/Textualize/textual/pull/4012 - Added `TabbedContent.active_pane` https://github.com/Textualize/textual/pull/4012 - Added support for environment variable `TEXTUAL_ANIMATIONS` to control what animations Textual displays https://github.com/Textualize/textual/pull/4062 -- Add attribute `App.show_animations` to control whether animations on that app run or not https://github.com/Textualize/textual/pull/4062 +- Add attribute `App.animation_level` to control whether animations on that app run or not https://github.com/Textualize/textual/pull/4062 ### Fixed diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 09e0a16d5d..1ed4c4575a 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -10,10 +10,9 @@ from . import _time from ._callback import invoke -from ._context import active_app from ._easing import DEFAULT_EASING, EASING from ._types import CallbackType -from .constants import AnimationsEnum +from .constants import AnimationLevel from .timer import Timer if TYPE_CHECKING: @@ -55,7 +54,11 @@ class Animation(ABC): """Callback to run after animation completes""" @abstractmethod - def __call__(self, time: float) -> bool: # pragma: no cover + def __call__( + self, + time: float, + app_animation_level: AnimationLevel = "full", + ) -> bool: # pragma: no cover """Call the animation, return a boolean indicating whether animation is in-progress or complete. Args: @@ -95,18 +98,17 @@ class SimpleAnimation(Animation): final_value: object easing: EasingFunction on_complete: CallbackType | None = None - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC + level: AnimationLevel = "full" """Minimum level required for the animation to take place (inclusive).""" - def __post_init__(self) -> None: - """Cache baseline that determines whether an animation runs.""" - # We cache this value so we don't have to `.get` the context var repeatedly. - self.show_animations = active_app.get().show_animations - - def __call__(self, time: float) -> bool: + def __call__( + self, time: float, app_animation_level: AnimationLevel = "full" + ) -> bool: if ( self.duration == 0 - or self.animate_on_level.value > self.show_animations.value + or app_animation_level == "none" + or app_animation_level == "basic" + and self.level == "full" ): setattr(self.obj, self.attribute, self.final_value) return True @@ -182,7 +184,7 @@ def __call__( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.FULL, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -195,7 +197,7 @@ def __call__( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ start_value = getattr(self._obj, attribute) if isinstance(value, str) and hasattr(start_value, "parse"): @@ -214,7 +216,7 @@ def __call__( delay=delay, easing=easing_function, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) @@ -299,7 +301,7 @@ def animate( easing: EasingFunction | str = DEFAULT_EASING, delay: float = 0.0, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.FULL, + level: AnimationLevel = "full", ) -> None: """Animate an attribute to a new value. @@ -313,7 +315,7 @@ def animate( easing: An easing function. delay: Number of seconds to delay the start of the animation by. on_complete: Callback to run after the animation completes. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ animate_callback = partial( self._animate, @@ -325,7 +327,7 @@ def animate( speed=speed, easing=easing, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) if delay: self._complete_event.clear() @@ -346,7 +348,7 @@ def _animate( speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.FULL, + level: AnimationLevel = "full", ) -> None: """Animate an attribute to a new value. @@ -359,7 +361,7 @@ def _animate( speed: The speed of the animation. easing: An easing function. on_complete: Callback to run after the animation completes. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ if not hasattr(obj, attribute): raise AttributeError( @@ -393,7 +395,7 @@ def _animate( speed=speed, easing=easing_function, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) if animation is None: @@ -431,7 +433,7 @@ def _animate( final_value=final_value, easing=easing_function, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) assert animation is not None, "animation expected to be non-None" @@ -512,11 +514,12 @@ async def __call__(self) -> None: if not self._scheduled: self._complete_event.set() else: + app_animation_level = self.app.animation_level animation_time = self._get_time() animation_keys = list(self._animations.keys()) for animation_key in animation_keys: animation = self._animations[animation_key] - animation_complete = animation(animation_time) + animation_complete = animation(animation_time, app_animation_level) if animation_complete: del self._animations[animation_key] await animation.invoke_callback() diff --git a/src/textual/app.py b/src/textual/app.py index b18940071b..151c3bc4ab 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -72,7 +72,7 @@ from .await_remove import AwaitRemove from .binding import Binding, BindingType, _Bindings from .command import CommandPalette, Provider -from .constants import AnimationsEnum +from .constants import AnimationLevel from .css.query import NoMatches from .css.stylesheet import RulesMap, Stylesheet from .design import ColorSystem @@ -576,10 +576,10 @@ def __init__( self.set_class(self.dark, "-dark-mode") self.set_class(not self.dark, "-light-mode") - self.show_animations: AnimationsEnum = constants._get_textual_animations() + self.animation_level: AnimationLevel = constants.TEXTUAL_ANIMATIONS """Determines what type of animations the app will display. - See [`textual.constants.SHOW_ANIMATIONS`][textual.constants.SHOW_ANIMATIONS]. + See [`textual.constants.TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS]. """ def validate_title(self, title: Any) -> str: @@ -677,7 +677,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.FULL, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -692,7 +692,7 @@ def animate( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ self._animate( attribute, @@ -703,7 +703,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) async def stop_animation(self, attribute: str, complete: bool = True) -> None: diff --git a/src/textual/constants.py b/src/textual/constants.py index 9d219d33ae..43adadf32d 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -4,28 +4,15 @@ from __future__ import annotations -import enum import os +from typing import Literal, get_args -from typing_extensions import Final +from typing_extensions import Final, TypeGuard get_environ = os.environ.get -class AnimationsEnum(enum.Enum): - """Valid values for the environment variable `TEXTUAL_ANIMATIONS`. - - - `NONE` disables all animations. - - `BASIC` will only display animations that don't delay content appearing - (e.g., scrolling). - - `FULL` displays all animations. - - See also: [`App.show_animations`][textual.app.App.show_animations]. - """ - - NONE = 0 - BASIC = 1 - FULL = 2 +AnimationLevel = Literal["none", "basic", "full"] def _get_environ_bool(name: str) -> bool: @@ -61,18 +48,31 @@ def _get_environ_int(name: str, default: int) -> int: return default -def _get_textual_animations() -> AnimationsEnum: +def _is_valid_animation_level(value: str) -> TypeGuard[AnimationLevel]: + """Checks if a string is a valid animation level. + + Args: + value: The string to check. + + Returns: + Whether it's a valid level or not. + """ + return value in get_args(AnimationLevel) + + +def _get_textual_animations() -> AnimationLevel: """Get the value of the environment variable that controls textual animations. - The variable can be in any of the values defined by [`AnimationsEnum`][textual.constants.AnimationsEnum]. + The variable can be in any of the values defined by [`AnimationLevel`][textual.constants.AnimationLevel]. Returns: The value that the variable was set to. If the environment variable is set to an invalid value, we default to showing all animations. """ - value: str = get_environ("TEXTUAL_ANIMATIONS", "FULL") - enum_value = AnimationsEnum.__members__.get(value, AnimationsEnum.FULL) - return enum_value + value: str = get_environ("TEXTUAL_ANIMATIONS", "FULL").lower() + if _is_valid_animation_level(value): + return value + return "full" DEBUG: Final[bool] = _get_environ_bool("TEXTUAL_DEBUG") @@ -107,3 +107,6 @@ def _get_textual_animations() -> AnimationsEnum: COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto") """Force color system override""" + +TEXTUAL_ANIMATIONS: AnimationLevel = _get_textual_animations() +"""Determines whether animations run or not.""" diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index dbb17d6f1e..5ceef5c4d4 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -3,9 +3,8 @@ from typing import TYPE_CHECKING from .._animator import Animation, EasingFunction -from .._context import active_app from .._types import CallbackType -from ..constants import AnimationsEnum +from ..constants import AnimationLevel from .scalar import Scalar, ScalarOffset if TYPE_CHECKING: @@ -25,7 +24,7 @@ def __init__( speed: float | None, easing: EasingFunction, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.FULL, + level: AnimationLevel = "full", ): assert ( speed is not None or duration is not None @@ -37,7 +36,7 @@ def __init__( self.final_value = value self.easing = easing self.on_complete = on_complete - self.animate_on_level = animate_on_level + self.level = level size = widget.outer_size viewport = widget.app.size @@ -52,20 +51,17 @@ def __init__( assert duration is not None, "Duration expected to be non-None" self.duration = duration - self.show_animations = active_app.get().show_animations - """Cached version of [`App.show_animations`][textual.app.App.show_animations]. - - Caching this avoids repeatedly getting the context variable when the animation - is supposed to play. - """ - - def __call__(self, time: float) -> bool: + def __call__( + self, time: float, app_animation_level: AnimationLevel = "full" + ) -> bool: factor = min(1.0, (time - self.start_time) / self.duration) eased_factor = self.easing(factor) if ( eased_factor >= 1 - or self.animate_on_level.value > self.show_animations.value + or app_animation_level == "none" + or app_animation_level == "basic" + and self.level == "full" ): setattr(self.styles, self.attribute, self.final_value) return True diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 9126a725d2..657ef7d376 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -13,7 +13,7 @@ from .._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from .._types import CallbackType from ..color import Color -from ..constants import AnimationsEnum +from ..constants import AnimationLevel from ..geometry import Offset, Spacing from ._style_properties import ( AlignProperty, @@ -370,7 +370,7 @@ def __textual_animation__( speed: float | None, easing: EasingFunction, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.FULL, + level: AnimationLevel = "full", ) -> ScalarAnimation | None: if self.node is None: return None @@ -398,7 +398,7 @@ def __textual_animation__( speed=speed, easing=easing, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) return None @@ -1141,7 +1141,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.FULL, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -1154,7 +1154,7 @@ def animate( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ if self._animate is None: assert self.node is not None @@ -1169,7 +1169,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def __rich_repr__(self) -> rich.repr.Result: diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index d8ba369cac..ea9e01452a 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -8,7 +8,7 @@ from ._animator import EasingFunction from ._types import CallbackType -from .constants import AnimationsEnum +from .constants import AnimationLevel from .containers import ScrollableContainer from .geometry import Region, Size @@ -120,7 +120,7 @@ def scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -133,7 +133,7 @@ def scroll_to( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ self._scroll_to( @@ -145,7 +145,7 @@ def scroll_to( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def refresh_lines(self, y_start: int, line_count: int = 1) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index b2b47fdde8..61ce22383f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -52,7 +52,7 @@ from .await_remove import AwaitRemove from .box_model import BoxModel from .cache import FIFOCache -from .constants import AnimationsEnum +from .constants import AnimationLevel from .css.query import NoMatches, WrongType from .css.scalar import ScalarOffset from .dom import DOMNode, NoScreen @@ -1729,7 +1729,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.FULL, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -1742,7 +1742,7 @@ def animate( delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ if self._animate is None: self._animate = self.app.animator.bind(self) @@ -1756,7 +1756,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) async def stop_animation(self, attribute: str, complete: bool = True) -> None: @@ -1903,7 +1903,7 @@ def _scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> bool: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1916,7 +1916,7 @@ def _scroll_to( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if the scroll position changed, otherwise `False`. @@ -1944,7 +1944,7 @@ def _scroll_to( duration=duration, easing=easing, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) scrolled_x = True if maybe_scroll_y: @@ -1958,7 +1958,7 @@ def _scroll_to( duration=duration, easing=easing, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) scrolled_y = True @@ -1990,7 +1990,7 @@ def scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -2003,7 +2003,7 @@ def scroll_to( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). Note: The call to scroll is made after the next refresh. @@ -2018,7 +2018,7 @@ def scroll_to( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_relative( @@ -2032,7 +2032,7 @@ def scroll_relative( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll relative to current position. @@ -2045,7 +2045,7 @@ def scroll_relative( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( None if x is None else (self.scroll_x + x), @@ -2056,7 +2056,7 @@ def scroll_relative( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_home( @@ -2068,7 +2068,7 @@ def scroll_home( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll to home position. @@ -2079,7 +2079,7 @@ def scroll_home( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 1.0 @@ -2092,7 +2092,7 @@ def scroll_home( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_end( @@ -2104,7 +2104,7 @@ def scroll_end( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll to the end of the container. @@ -2115,7 +2115,7 @@ def scroll_end( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 1.0 @@ -2137,7 +2137,7 @@ def _lazily_scroll_end() -> None: easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) self.call_after_refresh(_lazily_scroll_end) @@ -2151,7 +2151,7 @@ def scroll_left( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll one cell left. @@ -2162,7 +2162,7 @@ def scroll_left( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( x=self.scroll_target_x - 1, @@ -2172,7 +2172,7 @@ def scroll_left( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def _scroll_left_for_pointer( @@ -2184,7 +2184,7 @@ def _scroll_left_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> bool: """Scroll left one position, taking scroll sensitivity into account. @@ -2195,7 +2195,7 @@ def _scroll_left_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2212,7 +2212,7 @@ def _scroll_left_for_pointer( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_right( @@ -2224,7 +2224,7 @@ def scroll_right( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll one cell right. @@ -2235,7 +2235,7 @@ def scroll_right( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( x=self.scroll_target_x + 1, @@ -2245,7 +2245,7 @@ def scroll_right( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def _scroll_right_for_pointer( @@ -2257,7 +2257,7 @@ def _scroll_right_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> bool: """Scroll right one position, taking scroll sensitivity into account. @@ -2268,7 +2268,7 @@ def _scroll_right_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2285,7 +2285,7 @@ def _scroll_right_for_pointer( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_down( @@ -2297,7 +2297,7 @@ def scroll_down( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll one line down. @@ -2308,7 +2308,7 @@ def scroll_down( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_target_y + 1, @@ -2318,7 +2318,7 @@ def scroll_down( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def _scroll_down_for_pointer( @@ -2330,7 +2330,7 @@ def _scroll_down_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> bool: """Scroll down one position, taking scroll sensitivity into account. @@ -2341,7 +2341,7 @@ def _scroll_down_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2358,7 +2358,7 @@ def _scroll_down_for_pointer( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_up( @@ -2370,7 +2370,7 @@ def scroll_up( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll one line up. @@ -2381,7 +2381,7 @@ def scroll_up( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_target_y - 1, @@ -2391,7 +2391,7 @@ def scroll_up( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def _scroll_up_for_pointer( @@ -2403,7 +2403,7 @@ def _scroll_up_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> bool: """Scroll up one position, taking scroll sensitivity into account. @@ -2414,7 +2414,7 @@ def _scroll_up_for_pointer( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2431,7 +2431,7 @@ def _scroll_up_for_pointer( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_page_up( @@ -2443,7 +2443,7 @@ def scroll_page_up( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll one page up. @@ -2454,7 +2454,7 @@ def scroll_page_up( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y - self.container_size.height, @@ -2464,7 +2464,7 @@ def scroll_page_up( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_page_down( @@ -2476,7 +2476,7 @@ def scroll_page_down( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll one page down. @@ -2487,7 +2487,7 @@ def scroll_page_down( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y + self.container_size.height, @@ -2497,7 +2497,7 @@ def scroll_page_down( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_page_left( @@ -2509,7 +2509,7 @@ def scroll_page_left( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll one page left. @@ -2520,7 +2520,7 @@ def scroll_page_left( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 @@ -2532,7 +2532,7 @@ def scroll_page_left( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_page_right( @@ -2544,7 +2544,7 @@ def scroll_page_right( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll one page right. @@ -2555,7 +2555,7 @@ def scroll_page_right( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 @@ -2567,7 +2567,7 @@ def scroll_page_right( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_to_widget( @@ -2583,7 +2583,7 @@ def scroll_to_widget( origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> bool: """Scroll scrolling to bring a widget in to view. @@ -2597,7 +2597,7 @@ def scroll_to_widget( origin_visible: Ensure that the top left of the widget is within the window. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling has occurred in any descendant, otherwise `False`. @@ -2624,7 +2624,7 @@ def scroll_to_widget( origin_visible=origin_visible, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) if scroll_offset: scrolled = True @@ -2659,7 +2659,7 @@ def scroll_to_region( origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> Offset: """Scrolls a given region in to view, if required. @@ -2677,7 +2677,7 @@ def scroll_to_region( origin_visible: Ensure that the top left of the widget is within the window. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). Returns: The distance that was scrolled. @@ -2724,7 +2724,7 @@ def scroll_to_region( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) return delta @@ -2738,7 +2738,7 @@ def scroll_visible( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll the container to make this widget visible. @@ -2750,7 +2750,7 @@ def scroll_visible( easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ parent = self.parent if isinstance(parent, Widget): @@ -2764,7 +2764,7 @@ def scroll_visible( easing=easing, force=force, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def scroll_to_center( @@ -2778,7 +2778,7 @@ def scroll_to_center( force: bool = False, origin_visible: bool = True, on_complete: CallbackType | None = None, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, + level: AnimationLevel = "basic", ) -> None: """Scroll this widget to the center of self. @@ -2793,7 +2793,7 @@ def scroll_to_center( force: Force scrolling even when prohibited by overflow styling. origin_visible: Ensure that the top left corner of the widget remains visible after the scroll. on_complete: A callable to invoke when the animation is finished. - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ self.call_after_refresh( @@ -2807,7 +2807,7 @@ def scroll_to_center( center=True, origin_visible=origin_visible, on_complete=on_complete, - animate_on_level=animate_on_level, + level=level, ) def can_view(self, widget: Widget) -> bool: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 8e0c2526f6..d6f79f88e3 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -10,7 +10,7 @@ from .. import events from ..binding import Binding -from ..constants import AnimationsEnum +from ..constants import AnimationLevel from ..css._error_tools import friendly_list from ..message import Message from ..pad import HorizontalPad @@ -240,34 +240,29 @@ async def _on_click(self, event: events.Click) -> None: event.stop() self.press() - def press(self, *, animate_on_level: AnimationsEnum = AnimationsEnum.BASIC) -> Self: + def press(self, *, level: AnimationLevel = "basic") -> Self: """Respond to a button press. Args: - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). Returns: The button instance.""" if self.disabled or not self.display: return self # Manage the "active" effect: - self._start_active_affect(animate_on_level=animate_on_level) + self._start_active_affect(level=level) # ...and let other components know that we've just been clicked: self.post_message(Button.Pressed(self)) return self - def _start_active_affect( - self, *, animate_on_level: AnimationsEnum = AnimationsEnum.BASIC - ) -> None: + def _start_active_affect(self, *, level: AnimationLevel = "basic") -> None: """Start a small animation to show the button was clicked. Args: - animate_on_level: Minimum level required for the animation to take place (inclusive). + level: Minimum level required for the animation to take place (inclusive). """ - if ( - self.active_effect_duration > 0 - and self.app.show_animations.value >= animate_on_level.value - ): + if self.active_effect_duration > 0 and self.app.animation_level != "none": self.add_class("-active") self.set_timer( self.active_effect_duration, partial(self.remove_class, "-active") diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py index 2113740676..a826c85f85 100644 --- a/src/textual/widgets/_loading_indicator.py +++ b/src/textual/widgets/_loading_indicator.py @@ -7,7 +7,6 @@ from rich.text import Text from ..color import Gradient -from ..constants import AnimationsEnum from ..events import Mount from ..widget import Widget @@ -55,7 +54,7 @@ def _on_mount(self, _: Mount) -> None: self.auto_refresh = 1 / 16 def render(self) -> RenderableType: - if self.app.show_animations is AnimationsEnum.NONE: + if self.app.animation_level is "none": return Text("Loading...") elapsed = time() - self._start_time diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py index eff3f9d1ec..61bc91be8c 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -10,7 +10,6 @@ from .._types import UnusedParameter from ..app import ComposeResult, RenderResult -from ..constants import AnimationsEnum from ..containers import Horizontal from ..geometry import clamp from ..reactive import reactive @@ -106,7 +105,7 @@ def render_indeterminate(self) -> RenderResult: # Width used to enable the visual effect of the bar going into the corners. total_imaginary_width = width + highlighted_bar_width - if self.app.show_animations is AnimationsEnum.NONE: + if self.app.animation_level == "none": start = 0 end = width else: diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index 405e9c4161..12d5ebb7d0 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -5,7 +5,6 @@ from rich.console import RenderableType from ..binding import Binding, BindingType -from ..constants import AnimationsEnum from ..events import Click from ..geometry import Size from ..message import Message @@ -135,7 +134,7 @@ def watch_value(self, value: bool) -> None: "slider_pos", target_slider_pos, duration=0.3, - animate_on_level=AnimationsEnum.BASIC, + level="basic", ) else: self.slider_pos = target_slider_pos diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index b8bd9acc73..2bdf4b4d86 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -13,7 +13,6 @@ from ..app import ComposeResult, RenderResult from ..await_complete import AwaitComplete from ..binding import Binding, BindingType -from ..constants import AnimationsEnum from ..containers import Container, Horizontal, Vertical from ..css.query import NoMatches from ..events import Mount @@ -594,14 +593,11 @@ def watch_active(self, previously_active: str, active: str) -> None: def _highlight_active( self, animate: bool = True, - *, - animate_on_level: AnimationsEnum = AnimationsEnum.BASIC, ) -> None: """Move the underline bar to under the active tab. Args: animate: Should the bar animate? - animate_on_level: Minimum level required for the animation to take place (inclusive). """ underline = self.query_one(Underline) try: @@ -614,7 +610,8 @@ def _highlight_active( underline.show_highlight = True tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter) start, end = tab_region.column_span - if animate and self.app.show_animations.value >= animate_on_level.value: + # This is a basic animation, so we only disable it if we want no animations. + if animate and self.app.animation_level != "none": def animate_underline() -> None: """Animate the underline.""" @@ -631,13 +628,13 @@ def animate_underline() -> None: "highlight_start", start, duration=0.3, - animate_on_level=animate_on_level, + level="basic", ) underline.animate( "highlight_end", end, duration=0.3, - animate_on_level=animate_on_level, + level="basic", ) self.set_timer(0.02, lambda: self.call_after_refresh(animate_underline)) diff --git a/tests/animations/test_button_animation.py b/tests/animations/test_button_animation.py index 3a7b5e3444..afbe0a8531 100644 --- a/tests/animations/test_button_animation.py +++ b/tests/animations/test_button_animation.py @@ -4,7 +4,6 @@ """ from textual.app import App, ComposeResult -from textual.constants import AnimationsEnum from textual.widgets import Button @@ -16,7 +15,7 @@ def compose(self) -> ComposeResult: async def test_button_animates_on_full() -> None: """The button click animation should play on FULL.""" app = ButtonApp() - app.show_animations = AnimationsEnum.FULL + app.animation_level = "full" async with app.run_test() as pilot: await pilot.click(Button) @@ -26,7 +25,7 @@ async def test_button_animates_on_full() -> None: async def test_button_animates_on_basic() -> None: """The button click animation should play on BASIC.""" app = ButtonApp() - app.show_animations = AnimationsEnum.BASIC + app.animation_level = "basic" async with app.run_test() as pilot: await pilot.click(Button) @@ -36,7 +35,7 @@ async def test_button_animates_on_basic() -> None: async def test_button_does_not_animate_on_none() -> None: """The button click animation should play on NONE.""" app = ButtonApp() - app.show_animations = AnimationsEnum.NONE + app.animation_level = "none" async with app.run_test() as pilot: await pilot.click(Button) diff --git a/tests/animations/test_disabling_animations.py b/tests/animations/test_disabling_animations.py index 4e20fa3a90..ac013f3df9 100644 --- a/tests/animations/test_disabling_animations.py +++ b/tests/animations/test_disabling_animations.py @@ -4,7 +4,6 @@ from textual.app import App, ComposeResult from textual.color import Color -from textual.constants import AnimationsEnum from textual.widgets import Label @@ -23,43 +22,64 @@ def compose(self) -> ComposeResult: async def test_style_animations_via_animate_work_on_full() -> None: app = SingleLabelApp() - app.show_animations = AnimationsEnum.FULL + app.animation_level = "full" async with app.run_test() as pilot: label = app.query_one(Label) # Sanity check. assert label.styles.background == Color.parse("red") # Test. + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 label.styles.animate("background", "blue", duration=1) await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + await animator() assert label.styles.background != Color.parse("blue") async def test_style_animations_via_animate_are_disabled_on_basic() -> None: app = SingleLabelApp() - app.show_animations = AnimationsEnum.BASIC + app.animation_level = "basic" async with app.run_test() as pilot: label = app.query_one(Label) # Sanity check. assert label.styles.background == Color.parse("red") # Test. + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 label.styles.animate("background", "blue", duration=1) await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + await animator() assert label.styles.background == Color.parse("blue") async def test_style_animations_via_animate_are_disabled_on_none() -> None: app = SingleLabelApp() - app.show_animations = AnimationsEnum.NONE + app.animation_level = "none" async with app.run_test() as pilot: label = app.query_one(Label) # Sanity check. assert label.styles.background == Color.parse("red") # Test. + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 label.styles.animate("background", "blue", duration=1) await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + await animator() assert label.styles.background == Color.parse("blue") @@ -83,7 +103,7 @@ def compose(self) -> ComposeResult: async def test_style_animations_via_transition_work_on_full() -> None: app = LabelWithTransitionsApp() - app.show_animations = AnimationsEnum.FULL + app.animation_level = "full" async with app.run_test() as pilot: label = app.query_one(Label) @@ -97,7 +117,7 @@ async def test_style_animations_via_transition_work_on_full() -> None: async def test_style_animations_via_transition_are_disabled_on_basic() -> None: app = LabelWithTransitionsApp() - app.show_animations = AnimationsEnum.BASIC + app.animation_level = "basic" async with app.run_test() as pilot: label = app.query_one(Label) @@ -111,7 +131,7 @@ async def test_style_animations_via_transition_are_disabled_on_basic() -> None: async def test_style_animations_via_transition_are_disabled_on_none() -> None: app = LabelWithTransitionsApp() - app.show_animations = AnimationsEnum.NONE + app.animation_level = "none" async with app.run_test() as pilot: label = app.query_one(Label) diff --git a/tests/animations/test_environment_variable.py b/tests/animations/test_environment_variable.py index 727b4ce00c..49359d6a75 100644 --- a/tests/animations/test_environment_variable.py +++ b/tests/animations/test_environment_variable.py @@ -1,17 +1,18 @@ import pytest +from textual import constants from textual.app import App -from textual.constants import AnimationsEnum, _get_textual_animations +from textual.constants import _get_textual_animations @pytest.mark.parametrize( ["env_variable", "value"], [ - ("", AnimationsEnum.FULL), # default - ("FULL", AnimationsEnum.FULL), - ("BASIC", AnimationsEnum.BASIC), - ("NONE", AnimationsEnum.NONE), - ("garbanzo beans", AnimationsEnum.FULL), # fallback + ("", "full"), # default + ("FULL", "full"), + ("BASIC", "basic"), + ("NONE", "none"), + ("garbanzo beans", "full"), # fallback ], ) def test__get_textual_animations(monkeypatch, env_variable, value): # type: ignore @@ -21,17 +22,11 @@ def test__get_textual_animations(monkeypatch, env_variable, value): # type: ign @pytest.mark.parametrize( - ["env_variable", "value"], - [ - ("", AnimationsEnum.FULL), # default - ("FULL", AnimationsEnum.FULL), - ("BASIC", AnimationsEnum.BASIC), - ("NONE", AnimationsEnum.NONE), - ("garbanzo beans", AnimationsEnum.FULL), # fallback - ], + ["value"], + [("full",), ("basic",), ("none",)], ) -def test_app_show_animations(monkeypatch, env_variable, value): # type: ignore +def test_app_show_animations(monkeypatch, value): # type: ignore """Test that the app gets the value of `show_animations` correctly.""" - monkeypatch.setenv("TEXTUAL_ANIMATIONS", env_variable) + monkeypatch.setattr(constants, "TEXTUAL_ANIMATIONS", value) app = App() - assert app.show_animations == value + assert app.animation_level == value diff --git a/tests/animations/test_loading_indicator_animation.py b/tests/animations/test_loading_indicator_animation.py index 5c02079ec7..3f1df80a55 100644 --- a/tests/animations/test_loading_indicator_animation.py +++ b/tests/animations/test_loading_indicator_animation.py @@ -4,14 +4,13 @@ """ from textual.app import App -from textual.constants import AnimationsEnum from textual.widgets import LoadingIndicator async def test_loading_indicator_is_not_static_on_full() -> None: """The loading indicator doesn't fall back to the static render on FULL.""" app = App() - app.show_animations = AnimationsEnum.FULL + app.animation_level = "full" async with app.run_test() as pilot: app.screen.loading = True @@ -23,7 +22,7 @@ async def test_loading_indicator_is_not_static_on_full() -> None: async def test_loading_indicator_is_not_static_on_basic() -> None: """The loading indicator doesn't fall back to the static render on BASIC.""" app = App() - app.show_animations = AnimationsEnum.BASIC + app.animation_level = "basic" async with app.run_test() as pilot: app.screen.loading = True @@ -35,7 +34,7 @@ async def test_loading_indicator_is_not_static_on_basic() -> None: async def test_loading_indicator_is_static_on_none() -> None: """The loading indicator falls back to the static render on NONE.""" app = App() - app.show_animations = AnimationsEnum.NONE + app.animation_level = "none" async with app.run_test() as pilot: app.screen.loading = True diff --git a/tests/animations/test_progress_bar_animation.py b/tests/animations/test_progress_bar_animation.py index a677177c4b..82e2add599 100644 --- a/tests/animations/test_progress_bar_animation.py +++ b/tests/animations/test_progress_bar_animation.py @@ -4,7 +4,6 @@ """ from textual.app import App, ComposeResult -from textual.constants import AnimationsEnum from textual.widgets import ProgressBar from textual.widgets._progress_bar import Bar @@ -17,7 +16,7 @@ def compose(self) -> ComposeResult: async def test_progress_bar_animates_on_full() -> None: """An indeterminate progress bar is not fully highlighted when animating.""" app = ProgressBarApp() - app.show_animations = AnimationsEnum.FULL + app.animation_level = "full" async with app.run_test(): bar_renderable = app.query_one(Bar).render() @@ -28,7 +27,7 @@ async def test_progress_bar_animates_on_full() -> None: async def test_progress_bar_animates_on_basic() -> None: """An indeterminate progress bar is not fully highlighted when animating.""" app = ProgressBarApp() - app.show_animations = AnimationsEnum.BASIC + app.animation_level = "basic" async with app.run_test(): bar_renderable = app.query_one(Bar).render() @@ -39,7 +38,7 @@ async def test_progress_bar_animates_on_basic() -> None: async def test_progress_bar_does_not_animate_on_none() -> None: """An indeterminate progress bar is fully highlighted when not animating.""" app = ProgressBarApp() - app.show_animations = AnimationsEnum.NONE + app.animation_level = "none" async with app.run_test(): bar_renderable = app.query_one(Bar).render() diff --git a/tests/animations/test_scrolling_animation.py b/tests/animations/test_scrolling_animation.py index 3b13a873bf..8f95bbf5a0 100644 --- a/tests/animations/test_scrolling_animation.py +++ b/tests/animations/test_scrolling_animation.py @@ -4,7 +4,6 @@ """ from textual.app import App, ComposeResult -from textual.constants import AnimationsEnum from textual.containers import VerticalScroll from textual.widgets import Label @@ -18,41 +17,53 @@ def compose(self) -> ComposeResult: async def test_scrolling_animates_on_full() -> None: app = TallApp() - app.show_animations = AnimationsEnum.FULL + app.animation_level = "full" async with app.run_test() as pilot: vertical_scroll = app.query_one(VerticalScroll) - current_scroll = vertical_scroll.scroll_offset - # A ridiculously long duration means that in the fraction of a second - # we take to check the scroll position again we haven't moved yet. + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 vertical_scroll.scroll_end(duration=10000) await pilot.pause() - assert vertical_scroll.scroll_offset == current_scroll + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + await animator() + assert animator.is_being_animated(vertical_scroll, "scroll_y") async def test_scrolling_animates_on_basic() -> None: app = TallApp() - app.show_animations = AnimationsEnum.BASIC + app.animation_level = "basic" async with app.run_test() as pilot: vertical_scroll = app.query_one(VerticalScroll) - current_scroll = vertical_scroll.scroll_offset - # A ridiculously long duration means that in the fraction of a second - # we take to check the scroll position again we haven't moved yet. + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 vertical_scroll.scroll_end(duration=10000) await pilot.pause() - assert vertical_scroll.scroll_offset == current_scroll + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + await animator() + assert animator.is_being_animated(vertical_scroll, "scroll_y") async def test_scrolling_does_not_animate_on_none() -> None: app = TallApp() - app.show_animations = AnimationsEnum.NONE + app.animation_level = "none" async with app.run_test() as pilot: vertical_scroll = app.query_one(VerticalScroll) - current_scroll = vertical_scroll.scroll_offset - # Even with a supposedly really long scroll animation duration, - # we jump to the end because we're not animating. + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 vertical_scroll.scroll_end(duration=10000) await pilot.pause() - assert vertical_scroll.scroll_offset != current_scroll + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + await animator() + assert not animator.is_being_animated(vertical_scroll, "scroll_y") diff --git a/tests/animations/test_switch_animation.py b/tests/animations/test_switch_animation.py index 18ee1e9963..98bae2e6f8 100644 --- a/tests/animations/test_switch_animation.py +++ b/tests/animations/test_switch_animation.py @@ -4,7 +4,6 @@ """ from textual.app import App, ComposeResult -from textual.constants import AnimationsEnum from textual.widgets import Switch @@ -15,13 +14,18 @@ def compose(self) -> ComposeResult: async def test_switch_animates_on_full() -> None: app = SwitchApp() - app.show_animations = AnimationsEnum.FULL + app.animation_level = "full" - async with app.run_test(): + async with app.run_test() as pilot: switch = app.query_one(Switch) - switch.action_toggle() animator = app.animator - # Move to the next animation frame. + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + switch.action_toggle() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. await animator() # The animation should still be running. assert app.animator.is_being_animated(switch, "slider_pos") @@ -29,13 +33,18 @@ async def test_switch_animates_on_full() -> None: async def test_switch_animates_on_basic() -> None: app = SwitchApp() - app.show_animations = AnimationsEnum.BASIC + app.animation_level = "basic" - async with app.run_test(): + async with app.run_test() as pilot: switch = app.query_one(Switch) - switch.action_toggle() animator = app.animator - # Move to the next animation frame. + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + switch.action_toggle() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. await animator() # The animation should still be running. assert app.animator.is_being_animated(switch, "slider_pos") @@ -43,13 +52,18 @@ async def test_switch_animates_on_basic() -> None: async def test_switch_does_not_animate_on_none() -> None: app = SwitchApp() - app.show_animations = AnimationsEnum.NONE + app.animation_level = "none" - async with app.run_test(): + async with app.run_test() as pilot: switch = app.query_one(Switch) - switch.action_toggle() animator = app.animator - # Let the animator handle pending animations. + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + switch.action_toggle() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. await animator() - # The animation should be done. + # The animation should still be running. assert not app.animator.is_being_animated(switch, "slider_pos") diff --git a/tests/animations/test_tabs_underline_animation.py b/tests/animations/test_tabs_underline_animation.py index c94a317219..02e826eae9 100644 --- a/tests/animations/test_tabs_underline_animation.py +++ b/tests/animations/test_tabs_underline_animation.py @@ -4,7 +4,6 @@ """ from textual.app import App, ComposeResult -from textual.constants import AnimationsEnum from textual.widgets import Label, TabbedContent, Tabs from textual.widgets._tabs import Underline @@ -19,34 +18,58 @@ def compose(self) -> ComposeResult: async def test_tabs_underline_animates_on_full() -> None: """The underline takes some time to move when animated.""" app = TabbedContentApp() - app.show_animations = AnimationsEnum.FULL + app.animation_level = "full" - async with app.run_test(): + async with app.run_test() as pilot: underline = app.query_one(Underline) - before = underline._highlight_range + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 app.query_one(Tabs).action_previous_tab() - assert before == underline._highlight_range + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + await animator() + assert animator.is_being_animated(underline, "highlight_start") + assert animator.is_being_animated(underline, "highlight_end") async def test_tabs_underline_animates_on_basic() -> None: """The underline takes some time to move when animated.""" app = TabbedContentApp() - app.show_animations = AnimationsEnum.BASIC + app.animation_level = "basic" - async with app.run_test(): + async with app.run_test() as pilot: underline = app.query_one(Underline) - before = underline._highlight_range + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 app.query_one(Tabs).action_previous_tab() - assert before == underline._highlight_range + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + await animator() + assert animator.is_being_animated(underline, "highlight_start") + assert animator.is_being_animated(underline, "highlight_end") async def test_tabs_underline_does_not_animate_on_none() -> None: """The underline jumps to its final position when not animated.""" app = TabbedContentApp() - app.show_animations = AnimationsEnum.NONE + app.animation_level = "none" - async with app.run_test(): + async with app.run_test() as pilot: underline = app.query_one(Underline) - before = underline._highlight_range + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 app.query_one(Tabs).action_previous_tab() - assert before != underline._highlight_range + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + await animator() + assert not animator.is_being_animated(underline, "highlight_start") + assert not animator.is_being_animated(underline, "highlight_end") From 15b2063e2ad650285972ebb85e7637b66fc681b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 1 Feb 2024 10:27:44 +0000 Subject: [PATCH 10/13] Remove test flakiness. --- tests/animations/test_disabling_animations.py | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/tests/animations/test_disabling_animations.py b/tests/animations/test_disabling_animations.py index ac013f3df9..c2f6f7a980 100644 --- a/tests/animations/test_disabling_animations.py +++ b/tests/animations/test_disabling_animations.py @@ -24,20 +24,21 @@ async def test_style_animations_via_animate_work_on_full() -> None: app = SingleLabelApp() app.animation_level = "full" - async with app.run_test() as pilot: + async with app.run_test(): label = app.query_one(Label) # Sanity check. assert label.styles.background == Color.parse("red") - # Test. animator = app.animator # Freeze time at 0 before triggering the animation. animator._get_time = lambda *_: 0 label.styles.animate("background", "blue", duration=1) - await pilot.pause() - # Freeze time after the animation start and before animation end. - animator._get_time = lambda *_: 0.01 + assert len(animator._animations) > 0 # Sanity check. + # Freeze time around the animation midpoint. + animator._get_time = lambda *_: 0.5 # Move to the next frame. await animator() + # The animation shouldn't have completed. + assert label.styles.background != Color.parse("red") assert label.styles.background != Color.parse("blue") @@ -45,20 +46,20 @@ async def test_style_animations_via_animate_are_disabled_on_basic() -> None: app = SingleLabelApp() app.animation_level = "basic" - async with app.run_test() as pilot: + async with app.run_test(): label = app.query_one(Label) # Sanity check. assert label.styles.background == Color.parse("red") - # Test. animator = app.animator # Freeze time at 0 before triggering the animation. animator._get_time = lambda *_: 0 label.styles.animate("background", "blue", duration=1) - await pilot.pause() + assert len(animator._animations) > 0 # Sanity check. # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. await animator() + # The animation should have completed. assert label.styles.background == Color.parse("blue") @@ -66,20 +67,20 @@ async def test_style_animations_via_animate_are_disabled_on_none() -> None: app = SingleLabelApp() app.animation_level = "none" - async with app.run_test() as pilot: + async with app.run_test(): label = app.query_one(Label) # Sanity check. assert label.styles.background == Color.parse("red") - # Test. animator = app.animator # Freeze time at 0 before triggering the animation. animator._get_time = lambda *_: 0 label.styles.animate("background", "blue", duration=1) - await pilot.pause() + assert len(animator._animations) > 0 # Sanity check. # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. await animator() + # The animation should have completed. assert label.styles.background == Color.parse("blue") @@ -105,13 +106,20 @@ async def test_style_animations_via_transition_work_on_full() -> None: app = LabelWithTransitionsApp() app.animation_level = "full" - async with app.run_test() as pilot: + async with app.run_test(): label = app.query_one(Label) # Sanity check. assert label.styles.background == Color.parse("red") - # Test. + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 label.add_class("blue-bg") - await pilot.pause() + assert len(animator._animations) > 0 # Sanity check. + # Freeze time in the middle of the animation. + animator._get_time = lambda *_: 0.5 + await animator() + # The animation should be undergoing. + assert label.styles.background != Color.parse("red") assert label.styles.background != Color.parse("blue") @@ -119,13 +127,19 @@ async def test_style_animations_via_transition_are_disabled_on_basic() -> None: app = LabelWithTransitionsApp() app.animation_level = "basic" - async with app.run_test() as pilot: + async with app.run_test(): label = app.query_one(Label) # Sanity check. assert label.styles.background == Color.parse("red") - # Test. + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 label.add_class("blue-bg") - await pilot.pause() + assert len(animator._animations) > 0 # Sanity check. + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + await animator() + # The animation should have completed. assert label.styles.background == Color.parse("blue") @@ -133,11 +147,17 @@ async def test_style_animations_via_transition_are_disabled_on_none() -> None: app = LabelWithTransitionsApp() app.animation_level = "none" - async with app.run_test() as pilot: + async with app.run_test(): label = app.query_one(Label) # Sanity check. assert label.styles.background == Color.parse("red") - # Test. + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 label.add_class("blue-bg") - await pilot.pause() + assert len(animator._animations) > 0 # Sanity check. + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + await animator() + # The animation should have completed. assert label.styles.background == Color.parse("blue") From 350d53b313e7c1bf61e415bf4cc553eb50f5a120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 1 Feb 2024 15:23:41 +0000 Subject: [PATCH 11/13] Fix tests for 0.48.0 --- tests/animations/test_disabling_animations.py | 12 ++++++------ tests/animations/test_scrolling_animation.py | 6 +++--- tests/animations/test_switch_animation.py | 6 +++--- tests/animations/test_tabs_underline_animation.py | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/animations/test_disabling_animations.py b/tests/animations/test_disabling_animations.py index c2f6f7a980..2880262025 100644 --- a/tests/animations/test_disabling_animations.py +++ b/tests/animations/test_disabling_animations.py @@ -36,7 +36,7 @@ async def test_style_animations_via_animate_work_on_full() -> None: # Freeze time around the animation midpoint. animator._get_time = lambda *_: 0.5 # Move to the next frame. - await animator() + animator() # The animation shouldn't have completed. assert label.styles.background != Color.parse("red") assert label.styles.background != Color.parse("blue") @@ -58,7 +58,7 @@ async def test_style_animations_via_animate_are_disabled_on_basic() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() # The animation should have completed. assert label.styles.background == Color.parse("blue") @@ -79,7 +79,7 @@ async def test_style_animations_via_animate_are_disabled_on_none() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() # The animation should have completed. assert label.styles.background == Color.parse("blue") @@ -117,7 +117,7 @@ async def test_style_animations_via_transition_work_on_full() -> None: assert len(animator._animations) > 0 # Sanity check. # Freeze time in the middle of the animation. animator._get_time = lambda *_: 0.5 - await animator() + animator() # The animation should be undergoing. assert label.styles.background != Color.parse("red") assert label.styles.background != Color.parse("blue") @@ -138,7 +138,7 @@ async def test_style_animations_via_transition_are_disabled_on_basic() -> None: assert len(animator._animations) > 0 # Sanity check. # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 - await animator() + animator() # The animation should have completed. assert label.styles.background == Color.parse("blue") @@ -158,6 +158,6 @@ async def test_style_animations_via_transition_are_disabled_on_none() -> None: assert len(animator._animations) > 0 # Sanity check. # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 - await animator() + animator() # The animation should have completed. assert label.styles.background == Color.parse("blue") diff --git a/tests/animations/test_scrolling_animation.py b/tests/animations/test_scrolling_animation.py index 8f95bbf5a0..172dc09c61 100644 --- a/tests/animations/test_scrolling_animation.py +++ b/tests/animations/test_scrolling_animation.py @@ -29,7 +29,7 @@ async def test_scrolling_animates_on_full() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() assert animator.is_being_animated(vertical_scroll, "scroll_y") @@ -47,7 +47,7 @@ async def test_scrolling_animates_on_basic() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() assert animator.is_being_animated(vertical_scroll, "scroll_y") @@ -65,5 +65,5 @@ async def test_scrolling_does_not_animate_on_none() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() assert not animator.is_being_animated(vertical_scroll, "scroll_y") diff --git a/tests/animations/test_switch_animation.py b/tests/animations/test_switch_animation.py index 98bae2e6f8..333e6903f3 100644 --- a/tests/animations/test_switch_animation.py +++ b/tests/animations/test_switch_animation.py @@ -26,7 +26,7 @@ async def test_switch_animates_on_full() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() # The animation should still be running. assert app.animator.is_being_animated(switch, "slider_pos") @@ -45,7 +45,7 @@ async def test_switch_animates_on_basic() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() # The animation should still be running. assert app.animator.is_being_animated(switch, "slider_pos") @@ -64,6 +64,6 @@ async def test_switch_does_not_animate_on_none() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() # The animation should still be running. assert not app.animator.is_being_animated(switch, "slider_pos") diff --git a/tests/animations/test_tabs_underline_animation.py b/tests/animations/test_tabs_underline_animation.py index 02e826eae9..05e83e9e5d 100644 --- a/tests/animations/test_tabs_underline_animation.py +++ b/tests/animations/test_tabs_underline_animation.py @@ -30,7 +30,7 @@ async def test_tabs_underline_animates_on_full() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() assert animator.is_being_animated(underline, "highlight_start") assert animator.is_being_animated(underline, "highlight_end") @@ -50,7 +50,7 @@ async def test_tabs_underline_animates_on_basic() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() assert animator.is_being_animated(underline, "highlight_start") assert animator.is_being_animated(underline, "highlight_end") @@ -70,6 +70,6 @@ async def test_tabs_underline_does_not_animate_on_none() -> None: # Freeze time after the animation start and before animation end. animator._get_time = lambda *_: 0.01 # Move to the next frame. - await animator() + animator() assert not animator.is_being_animated(underline, "highlight_start") assert not animator.is_being_animated(underline, "highlight_end") From 14f83e57a1fdfc2bee8bab1c39233eedca3d651c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 8 Feb 2024 16:38:42 +0000 Subject: [PATCH 12/13] Move AnimationLevel to _types. See review comment: https://github.com/Textualize/textual/pull/4062#discussion_r1483111016. --- src/textual/_animator.py | 3 +-- src/textual/_types.py | 5 ++++- src/textual/app.py | 2 +- src/textual/constants.py | 7 +++---- src/textual/css/scalar_animation.py | 3 +-- src/textual/css/styles.py | 3 +-- src/textual/scroll_view.py | 3 +-- src/textual/types.py | 2 ++ src/textual/widget.py | 2 +- src/textual/widgets/_button.py | 2 +- 10 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 8f863e703c..27d6c96ce5 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -11,8 +11,7 @@ from . import _time from ._callback import invoke from ._easing import DEFAULT_EASING, EASING -from ._types import CallbackType -from .constants import AnimationLevel +from ._types import AnimationLevel, CallbackType from .timer import Timer if TYPE_CHECKING: diff --git a/src/textual/_types.py b/src/textual/_types.py index 4fe929f58e..da67f25574 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Union +from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Literal, Union from typing_extensions import Protocol @@ -40,3 +40,6 @@ class UnusedParameter: Callable[[Any, Any], None], ] """Type used for callbacks passed to the `watch` method of widgets.""" + +AnimationLevel = Literal["none", "basic", "full"] +"""The levels that the [`TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS] env var can be set to.""" diff --git a/src/textual/app.py b/src/textual/app.py index fe61292d40..a6d9b9d3ee 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -78,13 +78,13 @@ from ._context import message_hook as message_hook_context_var from ._event_broker import NoHandler, extract_handler_actions from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative +from ._types import AnimationLevel from ._wait import wait_for_idle from ._worker_manager import WorkerManager from .actions import ActionParseResult, SkipAction from .await_remove import AwaitRemove from .binding import Binding, BindingType, _Bindings from .command import CommandPalette, Provider -from .constants import AnimationLevel from .css.errors import StylesheetError from .css.query import NoMatches from .css.stylesheet import RulesMap, Stylesheet diff --git a/src/textual/constants.py b/src/textual/constants.py index 43adadf32d..74a91797e7 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -5,14 +5,13 @@ from __future__ import annotations import os -from typing import Literal, get_args +from typing import get_args from typing_extensions import Final, TypeGuard -get_environ = os.environ.get - +from ._types import AnimationLevel -AnimationLevel = Literal["none", "basic", "full"] +get_environ = os.environ.get def _get_environ_bool(name: str) -> bool: diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index 5ceef5c4d4..bf690b8a6c 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -3,8 +3,7 @@ from typing import TYPE_CHECKING from .._animator import Animation, EasingFunction -from .._types import CallbackType -from ..constants import AnimationLevel +from .._types import AnimationLevel, CallbackType from .scalar import Scalar, ScalarOffset if TYPE_CHECKING: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index e3554aeecb..8bc14d49c9 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -11,9 +11,8 @@ from typing_extensions import TypedDict from .._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction -from .._types import CallbackType +from .._types import AnimationLevel, CallbackType from ..color import Color -from ..constants import AnimationLevel from ..geometry import Offset, Spacing from ._style_properties import ( AlignProperty, diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index ea9e01452a..de7b9b3f2e 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -7,8 +7,7 @@ from rich.console import RenderableType from ._animator import EasingFunction -from ._types import CallbackType -from .constants import AnimationLevel +from ._types import AnimationLevel, CallbackType from .containers import ScrollableContainer from .geometry import Region, Size diff --git a/src/textual/types.py b/src/textual/types.py index 33be4449fe..f06841b93d 100644 --- a/src/textual/types.py +++ b/src/textual/types.py @@ -6,6 +6,7 @@ from ._context import NoActiveAppError from ._path import CSSPathError, CSSPathType from ._types import ( + AnimationLevel, CallbackType, IgnoreReturnCallbackType, MessageTarget, @@ -28,6 +29,7 @@ __all__ = [ "ActionParseResult", "Animatable", + "AnimationLevel", "CallbackType", "CSSPathError", "CSSPathType", diff --git a/src/textual/widget.py b/src/textual/widget.py index 85c4f36ddc..13673b0f2f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -48,11 +48,11 @@ from ._layout import Layout from ._segment_tools import align_lines from ._styles_cache import StylesCache +from ._types import AnimationLevel from .actions import SkipAction from .await_remove import AwaitRemove from .box_model import BoxModel from .cache import FIFOCache -from .constants import AnimationLevel from .css.query import NoMatches, WrongType from .css.scalar import ScalarOffset from .dom import DOMNode, NoScreen diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index d6f79f88e3..afcd36cae1 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -9,8 +9,8 @@ from typing_extensions import Literal, Self from .. import events +from .._types import AnimationLevel from ..binding import Binding -from ..constants import AnimationLevel from ..css._error_tools import friendly_list from ..message import Message from ..pad import HorizontalPad From 6b1e1660b087649408b36c560875c5f4e953164c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 8 Feb 2024 16:41:05 +0000 Subject: [PATCH 13/13] Never prevent the button click effect. See https://github.com/Textualize/textual/pull/4062#discussion_r1483127123. --- src/textual/widgets/_button.py | 9 +++-- tests/animations/test_button_animation.py | 42 ----------------------- 2 files changed, 4 insertions(+), 47 deletions(-) delete mode 100644 tests/animations/test_button_animation.py diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index afcd36cae1..ca88e11323 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -9,7 +9,6 @@ from typing_extensions import Literal, Self from .. import events -from .._types import AnimationLevel from ..binding import Binding from ..css._error_tools import friendly_list from ..message import Message @@ -240,7 +239,7 @@ async def _on_click(self, event: events.Click) -> None: event.stop() self.press() - def press(self, *, level: AnimationLevel = "basic") -> Self: + def press(self) -> Self: """Respond to a button press. Args: @@ -251,18 +250,18 @@ def press(self, *, level: AnimationLevel = "basic") -> Self: if self.disabled or not self.display: return self # Manage the "active" effect: - self._start_active_affect(level=level) + self._start_active_affect() # ...and let other components know that we've just been clicked: self.post_message(Button.Pressed(self)) return self - def _start_active_affect(self, *, level: AnimationLevel = "basic") -> None: + def _start_active_affect(self) -> None: """Start a small animation to show the button was clicked. Args: level: Minimum level required for the animation to take place (inclusive). """ - if self.active_effect_duration > 0 and self.app.animation_level != "none": + if self.active_effect_duration > 0: self.add_class("-active") self.set_timer( self.active_effect_duration, partial(self.remove_class, "-active") diff --git a/tests/animations/test_button_animation.py b/tests/animations/test_button_animation.py deleted file mode 100644 index afbe0a8531..0000000000 --- a/tests/animations/test_button_animation.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -Tests for the “button pressed” animation, which is considered a basic animation. -(An animation that also plays on the level BASIC.) -""" - -from textual.app import App, ComposeResult -from textual.widgets import Button - - -class ButtonApp(App[None]): - def compose(self) -> ComposeResult: - yield Button() - - -async def test_button_animates_on_full() -> None: - """The button click animation should play on FULL.""" - app = ButtonApp() - app.animation_level = "full" - - async with app.run_test() as pilot: - await pilot.click(Button) - assert app.query_one(Button).has_class("-active") - - -async def test_button_animates_on_basic() -> None: - """The button click animation should play on BASIC.""" - app = ButtonApp() - app.animation_level = "basic" - - async with app.run_test() as pilot: - await pilot.click(Button) - assert app.query_one(Button).has_class("-active") - - -async def test_button_does_not_animate_on_none() -> None: - """The button click animation should play on NONE.""" - app = ButtonApp() - app.animation_level = "none" - - async with app.run_test() as pilot: - await pilot.click(Button) - assert not app.query_one(Button).has_class("-active")