diff --git a/CHANGELOG.md b/CHANGELOG.md index c793d5f578..204fcb5f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Added + +- Added support for environment variable `TEXTUAL_ANIMATIONS` to control what animations Textual displays 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 + ## [0.51.0] - 2024-02-15 ### Added @@ -121,6 +128,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `App.suspend` https://github.com/Textualize/textual/pull/4064 - Added `App.action_suspend_process` https://github.com/Textualize/textual/pull/4064 + ### Fixed - Parameter `animate` from `DataTable.move_cursor` was being ignored https://github.com/Textualize/textual/issues/3840 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/_animator.py b/src/textual/_animator.py index 5ea15154ed..27d6c96ce5 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -11,7 +11,7 @@ from . import _time from ._callback import invoke from ._easing import DEFAULT_EASING, EASING -from ._types import CallbackType +from ._types import AnimationLevel, CallbackType from .timer import Timer if TYPE_CHECKING: @@ -53,7 +53,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: @@ -93,9 +97,18 @@ class SimpleAnimation(Animation): final_value: object easing: EasingFunction on_complete: CallbackType | None = None + level: AnimationLevel = "full" + """Minimum level required for the animation to take place (inclusive).""" - def __call__(self, time: float) -> bool: - if self.duration == 0: + def __call__( + self, time: float, app_animation_level: AnimationLevel = "full" + ) -> bool: + if ( + self.duration == 0 + or app_animation_level == "none" + or app_animation_level == "basic" + and self.level == "full" + ): setattr(self.obj, self.attribute, self.final_value) return True @@ -170,6 +183,7 @@ def __call__( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -182,6 +196,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. + 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 +215,7 @@ def __call__( delay=delay, easing=easing_function, on_complete=on_complete, + level=level, ) @@ -284,6 +300,7 @@ def animate( easing: EasingFunction | str = DEFAULT_EASING, delay: float = 0.0, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute to a new value. @@ -297,6 +314,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. + level: Minimum level required for the animation to take place (inclusive). """ animate_callback = partial( self._animate, @@ -308,6 +326,7 @@ def animate( speed=speed, easing=easing, on_complete=on_complete, + level=level, ) if delay: self._complete_event.clear() @@ -328,7 +347,8 @@ def _animate( speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, - ): + level: AnimationLevel = "full", + ) -> None: """Animate an attribute to a new value. Args: @@ -340,6 +360,7 @@ def _animate( speed: The speed of the animation. easing: An easing function. on_complete: Callback to run after the animation completes. + level: Minimum level required for the animation to take place (inclusive). """ if not hasattr(obj, attribute): raise AttributeError( @@ -373,6 +394,7 @@ def _animate( speed=speed, easing=easing_function, on_complete=on_complete, + level=level, ) if animation is None: @@ -414,6 +436,7 @@ def _animate( if on_complete is not None else None ), + level=level, ) assert animation is not None, "animation expected to be non-None" @@ -521,11 +544,12 @@ 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] if animation.on_complete is not None: 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/_types.py b/src/textual/_types.py index 75a28e7c7c..603d799f05 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 @@ -52,3 +52,6 @@ class UnusedParameter: WatchCallbackNoArgsType, ] """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 cc8f36633f..b2b1a81649 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -78,6 +78,7 @@ 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 @@ -614,6 +615,12 @@ def __init__( self.set_class(self.dark, "-dark-mode") self.set_class(not self.dark, "-light-mode") + self.animation_level: AnimationLevel = constants.TEXTUAL_ANIMATIONS + """Determines what type of animations the app will display. + + See [`textual.constants.TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS]. + """ + def validate_title(self, title: Any) -> str: """Make sure the title is set to a string.""" return str(title) @@ -709,6 +716,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -723,6 +731,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. + level: Minimum level required for the animation to take place (inclusive). """ self._animate( attribute, @@ -733,6 +742,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + 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 d47d0d2c15..74a91797e7 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -5,13 +5,16 @@ from __future__ import annotations import os +from typing import get_args -from typing_extensions import Final +from typing_extensions import Final, TypeGuard + +from ._types import AnimationLevel get_environ = os.environ.get -def get_environ_bool(name: str) -> bool: +def _get_environ_bool(name: str) -> bool: """Check an environment variable switch. Args: @@ -24,7 +27,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 +47,34 @@ def get_environ_int(name: str, default: int) -> int: return default -DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG") +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 [`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").lower() + if _is_valid_animation_level(value): + return value + return "full" + + +DEBUG: Final[bool] = _get_environ_bool("TEXTUAL_DEBUG") """Enable debug mode.""" DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None) @@ -59,20 +89,23 @@ 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""" + +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 018d28b191..bf690b8a6c 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from .._animator import Animation, EasingFunction -from .._types import CallbackType +from .._types import AnimationLevel, CallbackType from .scalar import Scalar, ScalarOffset if TYPE_CHECKING: @@ -23,6 +23,7 @@ def __init__( speed: float | None, easing: EasingFunction, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ): assert ( speed is not None or duration is not None @@ -34,6 +35,7 @@ def __init__( self.final_value = value self.easing = easing self.on_complete = on_complete + self.level = level size = widget.outer_size viewport = widget.app.size @@ -48,11 +50,18 @@ def __init__( assert duration is not None, "Duration expected to be non-None" self.duration = duration - 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: + if ( + eased_factor >= 1 + 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 476350648b..8bc14d49c9 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -11,7 +11,7 @@ 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 ..geometry import Offset, Spacing from ._style_properties import ( @@ -369,6 +369,7 @@ def __textual_animation__( speed: float | None, easing: EasingFunction, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> ScalarAnimation | None: if self.node is None: return None @@ -400,6 +401,7 @@ def __textual_animation__( if on_complete is not None else None ), + level=level, ) return None @@ -1142,6 +1144,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -1154,6 +1157,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. + level: Minimum level required for the animation to take place (inclusive). """ if self._animate is None: assert self.node is not None @@ -1168,6 +1172,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + level=level, ) def __rich_repr__(self) -> rich.repr.Result: diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 570bd3fafa..4e418ed350 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -7,7 +7,7 @@ from rich.console import RenderableType from ._animator import EasingFunction -from ._types import CallbackType +from ._types import AnimationLevel, CallbackType from .containers import ScrollableContainer from .geometry import Region, Size @@ -119,6 +119,7 @@ def scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -131,6 +132,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. + level: Minimum level required for the animation to take place (inclusive). """ self._scroll_to( @@ -142,6 +144,7 @@ def scroll_to( easing=easing, force=force, on_complete=on_complete, + level=level, ) def refresh_line(self, y: int) -> None: diff --git a/src/textual/types.py b/src/textual/types.py index 8ae7ec846d..95f33db4c2 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, @@ -29,6 +30,7 @@ __all__ = [ "ActionParseResult", "Animatable", + "AnimationLevel", "CallbackType", "CSSPathError", "CSSPathType", diff --git a/src/textual/widget.py b/src/textual/widget.py index 85daa5025e..4a3f6c12fe 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -48,6 +48,7 @@ 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 @@ -1720,6 +1721,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -1732,6 +1734,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. + level: Minimum level required for the animation to take place (inclusive). """ if self._animate is None: self._animate = self.app.animator.bind(self) @@ -1745,6 +1748,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + level=level, ) async def stop_animation(self, attribute: str, complete: bool = True) -> None: @@ -1891,6 +1895,7 @@ def _scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1903,6 +1908,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if the scroll position changed, otherwise `False`. @@ -1935,6 +1941,7 @@ def _scroll_to( duration=duration, easing=easing, on_complete=on_complete, + level=level, ) scrolled_x = True if maybe_scroll_y: @@ -1948,6 +1955,7 @@ def _scroll_to( duration=duration, easing=easing, on_complete=on_complete, + level=level, ) scrolled_y = True @@ -1979,6 +1987,7 @@ def scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1991,6 +2000,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. + level: Minimum level required for the animation to take place (inclusive). Note: The call to scroll is made after the next refresh. @@ -2005,6 +2015,7 @@ def scroll_to( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_relative( @@ -2018,6 +2029,7 @@ def scroll_relative( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll relative to current position. @@ -2030,6 +2042,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( None if x is None else (self.scroll_x + x), @@ -2040,6 +2053,7 @@ def scroll_relative( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_home( @@ -2051,6 +2065,7 @@ def scroll_home( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to home position. @@ -2061,6 +2076,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. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 1.0 @@ -2073,6 +2089,7 @@ def scroll_home( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_end( @@ -2084,6 +2101,7 @@ def scroll_end( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to the end of the container. @@ -2094,6 +2112,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. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 1.0 @@ -2115,6 +2134,7 @@ def _lazily_scroll_end() -> None: easing=easing, force=force, on_complete=on_complete, + level=level, ) self.call_after_refresh(_lazily_scroll_end) @@ -2128,6 +2148,7 @@ def scroll_left( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one cell left. @@ -2138,6 +2159,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( x=self.scroll_target_x - 1, @@ -2147,6 +2169,7 @@ def scroll_left( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_left_for_pointer( @@ -2158,6 +2181,7 @@ def _scroll_left_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll left one position, taking scroll sensitivity into account. @@ -2168,6 +2192,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2184,6 +2209,7 @@ def _scroll_left_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_right( @@ -2195,6 +2221,7 @@ def scroll_right( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one cell right. @@ -2205,6 +2232,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( x=self.scroll_target_x + 1, @@ -2214,6 +2242,7 @@ def scroll_right( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_right_for_pointer( @@ -2225,6 +2254,7 @@ def _scroll_right_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll right one position, taking scroll sensitivity into account. @@ -2235,6 +2265,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2251,6 +2282,7 @@ def _scroll_right_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_down( @@ -2262,6 +2294,7 @@ def scroll_down( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one line down. @@ -2272,6 +2305,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_target_y + 1, @@ -2281,6 +2315,7 @@ def scroll_down( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_down_for_pointer( @@ -2292,6 +2327,7 @@ def _scroll_down_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll down one position, taking scroll sensitivity into account. @@ -2302,6 +2338,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2318,6 +2355,7 @@ def _scroll_down_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_up( @@ -2329,6 +2367,7 @@ def scroll_up( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one line up. @@ -2339,6 +2378,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_target_y - 1, @@ -2348,6 +2388,7 @@ def scroll_up( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_up_for_pointer( @@ -2359,6 +2400,7 @@ def _scroll_up_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll up one position, taking scroll sensitivity into account. @@ -2369,6 +2411,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2385,6 +2428,7 @@ def _scroll_up_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_up( @@ -2396,6 +2440,7 @@ def scroll_page_up( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page up. @@ -2406,6 +2451,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y - self.container_size.height, @@ -2415,6 +2461,7 @@ def scroll_page_up( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_down( @@ -2426,6 +2473,7 @@ def scroll_page_down( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page down. @@ -2436,6 +2484,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y + self.container_size.height, @@ -2445,6 +2494,7 @@ def scroll_page_down( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_left( @@ -2456,6 +2506,7 @@ def scroll_page_left( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page left. @@ -2466,6 +2517,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. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 @@ -2477,6 +2529,7 @@ def scroll_page_left( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_right( @@ -2488,6 +2541,7 @@ def scroll_page_right( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page right. @@ -2498,6 +2552,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. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 @@ -2509,6 +2564,7 @@ def scroll_page_right( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_to_widget( @@ -2524,6 +2580,7 @@ def scroll_to_widget( origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll scrolling to bring a widget in to view. @@ -2537,6 +2594,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling has occurred in any descendant, otherwise `False`. @@ -2563,6 +2621,7 @@ def scroll_to_widget( origin_visible=origin_visible, force=force, on_complete=on_complete, + level=level, ) if scroll_offset: scrolled = True @@ -2597,6 +2656,7 @@ def scroll_to_region( origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> Offset: """Scrolls a given region in to view, if required. @@ -2614,6 +2674,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. + level: Minimum level required for the animation to take place (inclusive). Returns: The distance that was scrolled. @@ -2660,6 +2721,7 @@ def scroll_to_region( easing=easing, force=force, on_complete=on_complete, + level=level, ) return delta @@ -2673,6 +2735,7 @@ def scroll_visible( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll the container to make this widget visible. @@ -2684,6 +2747,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. + level: Minimum level required for the animation to take place (inclusive). """ parent = self.parent if isinstance(parent, Widget): @@ -2697,6 +2761,7 @@ def scroll_visible( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_to_center( @@ -2710,6 +2775,7 @@ def scroll_to_center( force: bool = False, origin_visible: bool = True, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll this widget to the center of self. @@ -2724,6 +2790,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. + level: Minimum level required for the animation to take place (inclusive). """ self.call_after_refresh( @@ -2737,6 +2804,7 @@ def scroll_to_center( center=True, origin_visible=origin_visible, on_complete=on_complete, + level=level, ) def can_view(self, widget: Widget) -> bool: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 83a3237b2d..ca88e11323 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -242,6 +242,9 @@ async def _on_click(self, event: events.Click) -> None: def press(self) -> Self: """Respond to a button press. + Args: + level: Minimum level required for the animation to take place (inclusive). + Returns: The button instance.""" if self.disabled or not self.display: @@ -253,7 +256,11 @@ def press(self) -> Self: return self def _start_active_affect(self) -> None: - """Start a small animation to show the button was clicked.""" + """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: self.add_class("-active") self.set_timer( diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py index e7cc4abb47..a826c85f85 100644 --- a/src/textual/widgets/_loading_indicator.py +++ b/src/textual/widgets/_loading_indicator.py @@ -54,6 +54,9 @@ def _on_mount(self, _: Mount) -> None: self.auto_refresh = 1 / 16 def render(self) -> RenderableType: + if self.app.animation_level is "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 6127f48f4f..fea9c2fba2 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -105,14 +105,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 self.app.animation_level == "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 2b8d4ccc39..96cce3eeb8 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -130,7 +130,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, + level="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 276f7e58ec..d67a30968d 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -591,7 +591,10 @@ 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, + ) -> None: """Move the underline bar to under the active tab. Args: @@ -608,7 +611,8 @@ 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: + # 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.""" @@ -621,8 +625,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, + level="basic", + ) + underline.animate( + "highlight_end", + end, + duration=0.3, + level="basic", + ) self.set_timer(0.02, lambda: self.call_after_refresh(animate_underline)) else: diff --git a/tests/animations/test_disabling_animations.py b/tests/animations/test_disabling_animations.py new file mode 100644 index 0000000000..2880262025 --- /dev/null +++ b/tests/animations/test_disabling_animations.py @@ -0,0 +1,163 @@ +""" +Test that generic animations can be disabled. +""" + +from textual.app import App, ComposeResult +from textual.color import Color +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.animation_level = "full" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.styles.animate("background", "blue", duration=1) + assert len(animator._animations) > 0 # Sanity check. + # Freeze time around the animation midpoint. + animator._get_time = lambda *_: 0.5 + # Move to the next frame. + animator() + # The animation shouldn't have completed. + assert label.styles.background != Color.parse("red") + assert label.styles.background != Color.parse("blue") + + +async def test_style_animations_via_animate_are_disabled_on_basic() -> None: + app = SingleLabelApp() + app.animation_level = "basic" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.styles.animate("background", "blue", duration=1) + 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. + animator() + # The animation should have completed. + assert label.styles.background == Color.parse("blue") + + +async def test_style_animations_via_animate_are_disabled_on_none() -> None: + app = SingleLabelApp() + app.animation_level = "none" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.styles.animate("background", "blue", duration=1) + 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. + animator() + # The animation should have completed. + 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.animation_level = "full" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.add_class("blue-bg") + assert len(animator._animations) > 0 # Sanity check. + # Freeze time in the middle of the animation. + animator._get_time = lambda *_: 0.5 + animator() + # The animation should be undergoing. + assert label.styles.background != Color.parse("red") + assert label.styles.background != Color.parse("blue") + + +async def test_style_animations_via_transition_are_disabled_on_basic() -> None: + app = LabelWithTransitionsApp() + app.animation_level = "basic" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.add_class("blue-bg") + assert len(animator._animations) > 0 # Sanity check. + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + animator() + # The animation should have completed. + assert label.styles.background == Color.parse("blue") + + +async def test_style_animations_via_transition_are_disabled_on_none() -> None: + app = LabelWithTransitionsApp() + app.animation_level = "none" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.add_class("blue-bg") + assert len(animator._animations) > 0 # Sanity check. + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + animator() + # The animation should have completed. + assert label.styles.background == Color.parse("blue") diff --git a/tests/animations/test_environment_variable.py b/tests/animations/test_environment_variable.py new file mode 100644 index 0000000000..49359d6a75 --- /dev/null +++ b/tests/animations/test_environment_variable.py @@ -0,0 +1,32 @@ +import pytest + +from textual import constants +from textual.app import App +from textual.constants import _get_textual_animations + + +@pytest.mark.parametrize( + ["env_variable", "value"], + [ + ("", "full"), # default + ("FULL", "full"), + ("BASIC", "basic"), + ("NONE", "none"), + ("garbanzo beans", "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( + ["value"], + [("full",), ("basic",), ("none",)], +) +def test_app_show_animations(monkeypatch, value): # type: ignore + """Test that the app gets the value of `show_animations` correctly.""" + monkeypatch.setattr(constants, "TEXTUAL_ANIMATIONS", value) + app = App() + assert app.animation_level == value diff --git a/tests/animations/test_loading_indicator_animation.py b/tests/animations/test_loading_indicator_animation.py new file mode 100644 index 0000000000..3f1df80a55 --- /dev/null +++ b/tests/animations/test_loading_indicator_animation.py @@ -0,0 +1,43 @@ +""" +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.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.animation_level = "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.animation_level = "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.animation_level = "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..82e2add599 --- /dev/null +++ b/tests/animations/test_progress_bar_animation.py @@ -0,0 +1,47 @@ +""" +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.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.animation_level = "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.animation_level = "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.animation_level = "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..172dc09c61 --- /dev/null +++ b/tests/animations/test_scrolling_animation.py @@ -0,0 +1,69 @@ +""" +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.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.animation_level = "full" + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + 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() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert animator.is_being_animated(vertical_scroll, "scroll_y") + + +async def test_scrolling_animates_on_basic() -> None: + app = TallApp() + app.animation_level = "basic" + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + 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() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert animator.is_being_animated(vertical_scroll, "scroll_y") + + +async def test_scrolling_does_not_animate_on_none() -> None: + app = TallApp() + app.animation_level = "none" + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + 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() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + 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 new file mode 100644 index 0000000000..333e6903f3 --- /dev/null +++ b/tests/animations/test_switch_animation.py @@ -0,0 +1,69 @@ +""" +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.widgets import Switch + + +class SwitchApp(App[None]): + def compose(self) -> ComposeResult: + yield Switch() + + +async def test_switch_animates_on_full() -> None: + app = SwitchApp() + app.animation_level = "full" + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + animator = app.animator + # 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. + 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.animation_level = "basic" + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + animator = app.animator + # 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. + 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.animation_level = "none" + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + animator = app.animator + # 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. + 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 new file mode 100644 index 0000000000..05e83e9e5d --- /dev/null +++ b/tests/animations/test_tabs_underline_animation.py @@ -0,0 +1,75 @@ +""" +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.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.animation_level = "full" + + async with app.run_test() as pilot: + underline = app.query_one(Underline) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + app.query_one(Tabs).action_previous_tab() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + 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.animation_level = "basic" + + async with app.run_test() as pilot: + underline = app.query_one(Underline) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + app.query_one(Tabs).action_previous_tab() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + 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.animation_level = "none" + + async with app.run_test() as pilot: + underline = app.query_one(Underline) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + app.query_one(Tabs).action_previous_tab() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert not animator.is_being_animated(underline, "highlight_start") + assert not animator.is_being_animated(underline, "highlight_end")