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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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 8400a68b7cdccb0145585e9c1c8a2cb7665f034a 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 11:54:01 +0000 Subject: [PATCH 12/41] Add Widget.lock, remove Tree.lock. --- CHANGELOG.md | 6 ++++++ src/textual/widget.py | 10 +++++++++- src/textual/widgets/_tree.py | 3 --- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 391eefa3a0..7410e73bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 an `asyncio` lock attribute `Widget.lock` to be used to synchronize widget state https://github.com/Textualize/textual/issues/4134 + ## [0.49.1] - 2023-02-08 ### Fixed diff --git a/src/textual/widget.py b/src/textual/widget.py index 85daa5025e..d2167c45f1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4,7 +4,7 @@ from __future__ import annotations -from asyncio import create_task, wait +from asyncio import Lock, create_task, wait from collections import Counter from fractions import Fraction from itertools import islice @@ -372,6 +372,14 @@ def __init__( if self.BORDER_SUBTITLE: self.border_subtitle = self.BORDER_SUBTITLE + self.lock = Lock() + """`asyncio` lock to be used to synchronize the state of the widget. + + Two different tasks might call methods on a widget at the same time, which + might result in a race condition. + This can be fixed by adding `async with widget.lock:` around the method calls. + """ + virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True) """The virtual (scrollable) [size][textual.geometry.Size] of the widget.""" diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 3e0a2d8cb4..f218ae86f2 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -2,7 +2,6 @@ from __future__ import annotations -from asyncio import Lock from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, Generic, Iterable, NewType, TypeVar, cast @@ -618,8 +617,6 @@ def __init__( self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1024) self._tree_lines_cached: list[_TreeLine[TreeDataType]] | None = None self._cursor_node: TreeNode[TreeDataType] | None = None - self.lock = Lock() - """Used to synchronise stateful directory tree operations.""" super().__init__(name=name, id=id, classes=classes, disabled=disabled) 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 13/41] 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 14/41] 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") From c6a7944791038ac364795987d984cdffca33007d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 16:26:53 +0000 Subject: [PATCH 15/41] Add support for a TEXTUAL_SCREENSHOT_LOCATION environment variable This will work in conjunction with TEXTUAL_SCREENSHOT, most of the time. --- src/textual/constants.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/textual/constants.py b/src/textual/constants.py index d47d0d2c15..8cb947d7ea 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -5,6 +5,7 @@ from __future__ import annotations import os +import pathlib from typing_extensions import Final @@ -65,6 +66,11 @@ def get_environ_int(name: str, default: int) -> int: SCREENSHOT_DELAY: Final[int] = get_environ_int("TEXTUAL_SCREENSHOT", -1) """Seconds delay before taking screenshot.""" +SCREENSHOT_LOCATION: Final[pathlib.Path | None] = pathlib.Path( + get_environ("TEXTUAL_SCREENSHOT_LOCATION", ".") +) +"""The location where screenshots should be written.""" + PRESS: Final[str] = get_environ("TEXTUAL_PRESS", "") """Keys to automatically press.""" From c680bd790516d959ed87c30b4aea0deabaeee056 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Fri, 16 Feb 2024 17:41:55 +0000 Subject: [PATCH 16/41] docs(events): add tcss to on decorator examples --- docs/guide/events.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/guide/events.md b/docs/guide/events.md index da18527f0c..bd526341c8 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -210,6 +210,12 @@ In the following example we have three buttons, each of which does something dif 1. The message handler is called when any button is pressed +=== "on_decorator.tcss" + + ```python title="on_decorator.tcss" + --8<-- "docs/examples/events/on_decorator.tcss" + ``` + === "Output" ```{.textual path="docs/examples/events/on_decorator01.py"} @@ -233,6 +239,12 @@ The following example uses the decorator approach to write individual message ha 2. Matches the button with class names "toggle" *and* "dark" 3. Matches the button with an id of "quit" +=== "on_decorator.tcss" + + ```python title="on_decorator.tcss" + --8<-- "docs/examples/events/on_decorator.tcss" + ``` + === "Output" ```{.textual path="docs/examples/events/on_decorator02.py"} From 9a9b00203758e371796fdd7342fd2e902863b403 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 18 Feb 2024 14:07:39 +0000 Subject: [PATCH 17/41] signals env var --- src/textual/drivers/linux_driver.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 78628f6ae4..fcd232753a 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -42,8 +42,8 @@ def __init__( size: Initial size of the terminal or `None` to detect. """ super().__init__(app, debug=debug, size=size) - self._file = sys.__stdout__ - self.fileno = sys.stdin.fileno() + self._file = sys.__stderr__ + self.fileno = sys.__stdin__.fileno() self.attrs_before: list[Any] | None = None self.exit_event = Event() self._key_thread: Thread | None = None @@ -259,7 +259,9 @@ def _request_terminal_sync_mode_support(self) -> None: @classmethod def _patch_lflag(cls, attrs: int) -> int: - return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG) + ISIG = 0 if os.environ.get("TEXTUAL_ALLOW_SIGNALS") else termios.ISIG + + return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | ISIG) @classmethod def _patch_iflag(cls, attrs: int) -> int: @@ -328,15 +330,16 @@ def _run_input_thread(self) -> None: def run_input_thread(self) -> None: """Wait for input and dispatch events.""" - selector = selectors.DefaultSelector() + selector = selectors.SelectSelector() selector.register(self.fileno, selectors.EVENT_READ) fileno = self.fileno + EVENT_READ = selectors.EVENT_READ def more_data() -> bool: """Check if there is more data to parse.""" - EVENT_READ = selectors.EVENT_READ - for key, events in selector.select(0.01): + + for _key, events in selector.select(0.01): if events & EVENT_READ: return True return False @@ -347,14 +350,15 @@ def more_data() -> bool: utf8_decoder = getincrementaldecoder("utf-8")().decode decode = utf8_decoder read = os.read - EVENT_READ = selectors.EVENT_READ try: while not self.exit_event.is_set(): selector_events = selector.select(0.1) for _selector_key, mask in selector_events: if mask & EVENT_READ: - unicode_data = decode(read(fileno, 1024)) + unicode_data = decode( + read(fileno, 1024), final=self.exit_event.is_set() + ) for event in feed(unicode_data): self.process_event(event) finally: From fe182918bacd4e99bbb80c653f322eda18789bd0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 18 Feb 2024 14:11:45 +0000 Subject: [PATCH 18/41] docstring --- src/textual/drivers/linux_driver.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index fcd232753a..275075ec1c 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -259,6 +259,16 @@ def _request_terminal_sync_mode_support(self) -> None: @classmethod def _patch_lflag(cls, attrs: int) -> int: + """Patch termios lflag. + + Args: + attributes: New set attributes. + + Returns: + New lflag. + + """ + # if TEXTUAL_ALLOW_SIGNALS env var is set, then allow Ctrl+C to send signals ISIG = 0 if os.environ.get("TEXTUAL_ALLOW_SIGNALS") else termios.ISIG return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | ISIG) From 8d4489e1061c9af71101c481c7ec5f76e0f9e60b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 19 Feb 2024 08:40:42 +0000 Subject: [PATCH 19/41] Remove double-up scrolling of active tab Follows on from #4159; in that PR we do the scrolling when seeing the active tab (because that could be done from code and it might not be in view and it needs to be dragged into view); this resulted in the unintended consequence of the animation being kicked off twice, presumably causing the previous instance to be forced to finish instantly, thus making it look like it didn't animate at all. Fixes #4169 (or at least, as I'm testing it, it fixes #4169, but some doubt has be raised about this so this is first offered as a double-check) --- src/textual/widgets/_tabs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 276f7e58ec..1948bb9ceb 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -644,7 +644,6 @@ def _activate_tab(self, tab: Tab) -> None: self.query("#tabs-list Tab.-active").remove_class("-active") tab.add_class("-active") self.active = tab.id or "" - self.query_one("#tabs-scroll").scroll_to_center(tab, force=True) def _on_underline_clicked(self, event: Underline.Clicked) -> None: """The underline was clicked. From de1ee9fddd171447439edca01fccab5b4341b889 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 19 Feb 2024 11:32:51 +0000 Subject: [PATCH 20/41] Make the screenshot location a string on None --- src/textual/constants.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/textual/constants.py b/src/textual/constants.py index 8cb947d7ea..9129c1206f 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -5,7 +5,6 @@ from __future__ import annotations import os -import pathlib from typing_extensions import Final @@ -66,9 +65,7 @@ def get_environ_int(name: str, default: int) -> int: SCREENSHOT_DELAY: Final[int] = get_environ_int("TEXTUAL_SCREENSHOT", -1) """Seconds delay before taking screenshot.""" -SCREENSHOT_LOCATION: Final[pathlib.Path | None] = pathlib.Path( - get_environ("TEXTUAL_SCREENSHOT_LOCATION", ".") -) +SCREENSHOT_LOCATION: Final[str | None] = get_environ("TEXTUAL_SCREENSHOT_LOCATION") """The location where screenshots should be written.""" PRESS: Final[str] = get_environ("TEXTUAL_PRESS", "") From e39e0b5329650a76cae9b63c7eec605f32c34fba Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 19 Feb 2024 11:34:21 +0000 Subject: [PATCH 21/41] Allow None to be passed as the path to mean use the default --- src/textual/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index cc8f36633f..e9dbde5efa 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1143,7 +1143,7 @@ def export_screenshot(self, *, title: str | None = None) -> str: def save_screenshot( self, filename: str | None = None, - path: str = "./", + path: str | None = None, time_format: str | None = None, ) -> str: """Save an SVG screenshot of the current screen. @@ -1158,6 +1158,7 @@ def save_screenshot( Returns: Filename of screenshot. """ + path = "./" if path is None else path if filename is None: if time_format is None: dt = datetime.now().isoformat() From e396cf4ef1a0a02a1b2d92db68a25164ee8dc2e6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 19 Feb 2024 12:07:28 +0000 Subject: [PATCH 22/41] Add support for setting the auto-screenshot filename --- src/textual/app.py | 5 ++++- src/textual/constants.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index e9dbde5efa..2dcd497d59 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2388,7 +2388,10 @@ async def _ready(self) -> None: async def take_screenshot() -> None: """Take a screenshot and exit.""" - self.save_screenshot() + self.save_screenshot( + path=constants.SCREENSHOT_LOCATION, + filename=constants.SCREENSHOT_FILENAME, + ) self.exit() if constants.SCREENSHOT_DELAY >= 0: diff --git a/src/textual/constants.py b/src/textual/constants.py index 9129c1206f..aca561a47d 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -68,6 +68,9 @@ def get_environ_int(name: str, default: int) -> int: SCREENSHOT_LOCATION: Final[str | None] = get_environ("TEXTUAL_SCREENSHOT_LOCATION") """The location where screenshots should be written.""" +SCREENSHOT_FILENAME: Final[str | None] = get_environ("TEXTUAL_SCREENSHOT_FILENAME") +"""The filename to use for the screenshot.""" + PRESS: Final[str] = get_environ("TEXTUAL_PRESS", "") """Keys to automatically press.""" From 69a2be9be31796a330a884c703d0baeaa89e37fa Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 19 Feb 2024 13:26:22 +0000 Subject: [PATCH 23/41] Add new items to the ChangeLog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5826989c77..6250c84295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - TextArea now has `read_only` mode https://github.com/Textualize/textual/pull/4151 - Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149 - Add undo and redo to TextArea https://github.com/Textualize/textual/pull/4124 +- Added support for a `TEXTUAL_SCREENSHOT_LOCATION` environment variable to specify the location of an automated screenshot +- Added support for a `TEXTUAL_SCREENSHOT_FILENAME` environment variable to specify the filename of an automated screenshot ### Changed @@ -31,7 +33,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030 - Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878 -### Added +### Added - Added support for configuring dark and light themes for code in `Markdown` https://github.com/Textualize/textual/issues/3997 From 8ae4f0403dd4a21a513ff6020a75c75f7a4383bc 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: Mon, 19 Feb 2024 13:58:52 +0000 Subject: [PATCH 24/41] Add 'selector' to 'Widget.remove_children'. --- CHANGELOG.md | 1 + src/textual/widget.py | 16 +++++-- tests/test_widget_removing.py | 87 +++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df908cb1c..bb5eb11fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added an `asyncio` lock attribute `Widget.lock` to be used to synchronize widget state https://github.com/Textualize/textual/issues/4134 +- `Widget.remove_children` now accepts a CSS selector to specify which children to remove https://github.com/Textualize/textual/issues/4133 ## [0.51.0] - 2024-02-15 diff --git a/src/textual/widget.py b/src/textual/widget.py index d2167c45f1..a52ba4c4f1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -77,6 +77,7 @@ if TYPE_CHECKING: from .app import App, ComposeResult + from .css.query import QueryType from .message_pump import MessagePump from .scrollbar import ( ScrollBar, @@ -3223,13 +3224,22 @@ def remove(self) -> AwaitRemove: await_remove = self.app._remove_nodes([self], self.parent) return await_remove - def remove_children(self) -> AwaitRemove: - """Remove all children of this Widget from the DOM. + def remove_children( + self, selector: str | type[QueryType] | None = None + ) -> AwaitRemove: + """Remove the children of this Widget from the DOM. + + Args: + selector: A CSS selector to specify which children to remove. Returns: An awaitable object that waits for the children to be removed. """ - await_remove = self.app._remove_nodes(list(self.children), self) + if isinstance(selector, str) or selector is None: + children_to_remove = self.query(selector) + else: + children_to_remove = self.query(selector.__name__) + await_remove = self.app._remove_nodes(list(children_to_remove), self) return await_remove def render(self) -> RenderableType: diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py index ddb9dae16a..d81867e5c7 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -141,6 +141,79 @@ async def test_widget_remove_children_container(): assert len(container.children) == 0 +async def test_widget_remove_children_with_star_selector(): + app = ExampleApp() + async with app.run_test(): + container = app.query_one(Vertical) + + # 6 labels in total, with 5 of them inside the container. + assert len(app.query(Label)) == 6 + assert len(container.children) == 5 + + await container.remove_children("*") + + # The labels inside the container are gone, and the 1 outside remains. + assert len(app.query(Label)) == 1 + assert len(container.children) == 0 + + +async def test_widget_remove_children_with_string_selector(): + app = ExampleApp() + async with app.run_test(): + container = app.query_one(Vertical) + + # 6 labels in total, with 5 of them inside the container. + assert len(app.query(Label)) == 6 + assert len(container.children) == 5 + + await app.screen.remove_children("Label") + + # All labels are gone but the button and the container remain. + assert len(app.query(Button)) == 1 + assert len(app.query(Vertical)) == 1 + assert len(app.query(Label)) == 0 + + +async def test_widget_remove_children_with_type_selector(): + app = ExampleApp() + async with app.run_test(): + assert len(app.query(Button)) == 1 # Sanity check. + await app.screen.remove_children(Button) + assert len(app.query(Button)) == 0 + + +async def test_widget_remove_children_with_selector_does_not_leak(): + app = ExampleApp() + async with app.run_test(): + container = app.query_one(Vertical) + + # 6 labels in total, with 5 of them inside the container. + assert len(app.query(Label)) == 6 + assert len(container.children) == 5 + + await container.remove_children("Label") + + # The labels inside the container are gone, and the 1 outside remains. + assert len(app.query(Label)) == 1 + assert len(container.children) == 0 + + +async def test_widget_remove_children_with_compound_selector(): + app = ExampleApp() + async with app.run_test(): + container = app.query_one(Vertical) + + # 6 labels in total, with 5 of them inside the container. + assert len(app.query(Label)) == 6 + assert len(container.children) == 5 + + await app.screen.remove_children("Vertical > Label") + + # The labels inside the container are gone, and the 1 outside remains. + assert len(app.query(Label)) == 1 + assert len(container.children) == 0 + + async def test_widget_remove_children_no_children(): app = ExampleApp() async with app.run_test(): @@ -154,3 +227,17 @@ async def test_widget_remove_children_no_children(): assert ( count_before == count_after ) # No widgets have been removed, since Button has no children. + + +async def test_widget_remove_children_no_children_match_selector(): + app = ExampleApp() + async with app.run_test(): + container = app.query_one(Vertical) + assert len(container.query("Button")) == 0 # Sanity check. + + count_before = len(app.query("*")) + container_children_before = list(container.children) + await container.remove_children("Button") + + assert count_before == len(app.query("*")) + assert container_children_before == list(container.children) From 88efbbabee8ca932c5863619abd81cf2731573d9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 19 Feb 2024 14:08:29 +0000 Subject: [PATCH 25/41] Link the ChangeLog to the PR --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ebc73b6ed..a87e7f31f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added support for a `TEXTUAL_SCREENSHOT_LOCATION` environment variable to specify the location of an automated screenshot -- Added support for a `TEXTUAL_SCREENSHOT_FILENAME` environment variable to specify the filename of an automated screenshot +- Added support for a `TEXTUAL_SCREENSHOT_LOCATION` environment variable to specify the location of an automated screenshot https://github.com/Textualize/textual/pull/4181/ +- Added support for a `TEXTUAL_SCREENSHOT_FILENAME` environment variable to specify the filename of an automated screenshot https://github.com/Textualize/textual/pull/4181/ ## [0.51.0] - 2024-02-15 From 6438811a18acdbc5b5f6e5222c0aa3e5801dbf97 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 19 Feb 2024 14:21:45 +0000 Subject: [PATCH 26/41] remove dynamic version --- src/textual/__init__.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 0b93010125..2a73640c7b 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import inspect +from importlib.metadata import version from typing import TYPE_CHECKING, Callable import rich.repr @@ -16,26 +17,17 @@ from typing_extensions import TypeAlias __all__ = [ - "__version__", # type: ignore + "__version__", "log", "on", "panic", "work", ] - +__version__ = version("textual") LogCallable: TypeAlias = "Callable" -def __getattr__(name: str) -> str: - """Lazily get the version.""" - if name == "__version__": - from importlib.metadata import version - - return version("textual") - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - class LoggerError(Exception): """Raised when the logger failed.""" From d3a2a0a30ba968ab148816a218128ce1272a5368 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 19 Feb 2024 14:32:03 +0000 Subject: [PATCH 27/41] Allow empty path or filename to also mean default --- src/textual/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 2dcd497d59..12b6c2bd53 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1158,8 +1158,8 @@ def save_screenshot( Returns: Filename of screenshot. """ - path = "./" if path is None else path - if filename is None: + path = path or "./" + if not filename: if time_format is None: dt = datetime.now().isoformat() else: From b6f565c664e6a80638b27737693cc7c109a35ea2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 19 Feb 2024 14:32:05 +0000 Subject: [PATCH 28/41] restore dynamic behaviour --- src/textual/__init__.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 2a73640c7b..64fa99601b 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations import inspect -from importlib.metadata import version from typing import TYPE_CHECKING, Callable import rich.repr @@ -17,17 +16,34 @@ from typing_extensions import TypeAlias __all__ = [ - "__version__", + "__version__", # type: ignore "log", "on", "panic", "work", ] -__version__ = version("textual") + LogCallable: TypeAlias = "Callable" +if TYPE_CHECKING: + + from importlib.metadata import version + + __version__ = version("textual") + +else: + + def __getattr__(name: str) -> str: + """Lazily get the version.""" + if name == "__version__": + from importlib.metadata import version + + return version("textual") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + class LoggerError(Exception): """Raised when the logger failed.""" From 9d29bc395da53eccfb2e3b85586ee4dea8554fe1 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: Mon, 19 Feb 2024 14:48:42 +0000 Subject: [PATCH 29/41] Add 'Widget.batch'. --- src/textual/widget.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index a52ba4c4f1..41f65312af 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -6,11 +6,13 @@ from asyncio import Lock, create_task, wait from collections import Counter +from contextlib import asynccontextmanager from fractions import Fraction from itertools import islice from types import TracebackType from typing import ( TYPE_CHECKING, + AsyncGenerator, Awaitable, ClassVar, Collection, @@ -3242,6 +3244,24 @@ def remove_children( await_remove = self.app._remove_nodes(list(children_to_remove), self) return await_remove + @asynccontextmanager + async def batch(self) -> AsyncGenerator[None, None]: + """Async context manager that combines widget locking and update batching. + + Use this async context manager whenever you want to acquire the widget lock and + batch app updates at the same time. + + Example: + ```py + async with container.batch(): + await container.remove_children(Button) + await container.mount(Label("All buttons are gone.")) + ``` + """ + async with self.lock: + with self.app.batch_update(): + yield + def render(self) -> RenderableType: """Get text or Rich renderable for this widget. From 8ef3a6461992ebb50fe880af69ee8e264735eb9e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 19 Feb 2024 14:49:41 +0000 Subject: [PATCH 30/41] Don't double-scroll tab into view on keyboard move --- src/textual/widgets/_tabs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 1948bb9ceb..cc12d7460f 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -699,7 +699,6 @@ def _move_tab(self, direction: int) -> None: tab_count = len(tabs) new_tab_index = (tabs.index(active_tab) + direction) % tab_count self.active = tabs[new_tab_index].id or "" - self._scroll_active_tab() def _on_tab_disabled(self, event: Tab.Disabled) -> None: """Re-post the disabled message.""" From 30443badf5735f4d7b37d19d0fe221fe16715dc7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 19 Feb 2024 15:07:58 +0000 Subject: [PATCH 31/41] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c793d5f578..0379662240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +### Changed + +- Textual now writes to stderr rather than stdout + ## [0.51.0] - 2024-02-15 ### Added From 808f536258ade532db633a7c5a0a26d5212f5cd9 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: Mon, 19 Feb 2024 15:43:26 +0000 Subject: [PATCH 32/41] 'Widget.remove_children' should remove direct children only. Relevant feedback comment: https://github.com/Textualize/textual/pull/4183#discussion_r1494705055. --- src/textual/widget.py | 24 +++++++++++++----------- tests/test_widget_removing.py | 20 ++------------------ 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index aa56faae2f..157e0cb930 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -55,6 +55,8 @@ from .await_remove import AwaitRemove from .box_model import BoxModel from .cache import FIFOCache +from .css.match import match +from .css.parse import parse_selectors from .css.query import NoMatches, WrongType from .css.scalar import ScalarOffset from .dom import DOMNode, NoScreen @@ -3294,22 +3296,22 @@ def remove(self) -> AwaitRemove: await_remove = self.app._remove_nodes([self], self.parent) return await_remove - def remove_children( - self, selector: str | type[QueryType] | None = None - ) -> AwaitRemove: - """Remove the children of this Widget from the DOM. + def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitRemove: + """Remove the immediate children of this Widget from the DOM. Args: - selector: A CSS selector to specify which children to remove. + selector: A CSS selector to specify which direct children to remove. Returns: - An awaitable object that waits for the children to be removed. + An awaitable object that waits for the direct children to be removed. """ - if isinstance(selector, str) or selector is None: - children_to_remove = self.query(selector) - else: - children_to_remove = self.query(selector.__name__) - await_remove = self.app._remove_nodes(list(children_to_remove), self) + if not isinstance(selector, str): + selector = selector.__name__ + parsed_selectors = parse_selectors(selector) + children_to_remove = [ + child for child in self.children if match(parsed_selectors, child) + ] + await_remove = self.app._remove_nodes(children_to_remove, self) return await_remove @asynccontextmanager diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py index d81867e5c7..7ffb624d77 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -168,10 +168,10 @@ async def test_widget_remove_children_with_string_selector(): await app.screen.remove_children("Label") - # All labels are gone but the button and the container remain. + # Only the Screen > Label widget is gone, everything else remains. assert len(app.query(Button)) == 1 assert len(app.query(Vertical)) == 1 - assert len(app.query(Label)) == 0 + assert len(app.query(Label)) == 5 async def test_widget_remove_children_with_type_selector(): @@ -198,22 +198,6 @@ async def test_widget_remove_children_with_selector_does_not_leak(): assert len(container.children) == 0 -async def test_widget_remove_children_with_compound_selector(): - app = ExampleApp() - async with app.run_test(): - container = app.query_one(Vertical) - - # 6 labels in total, with 5 of them inside the container. - assert len(app.query(Label)) == 6 - assert len(container.children) == 5 - - await app.screen.remove_children("Vertical > Label") - - # The labels inside the container are gone, and the 1 outside remains. - assert len(app.query(Label)) == 1 - assert len(container.children) == 0 - - async def test_widget_remove_children_no_children(): app = ExampleApp() async with app.run_test(): From da7171f7f1f698b8d5559cfb630c6ad722f86579 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 19 Feb 2024 16:21:34 +0000 Subject: [PATCH 33/41] changelog --- CHANGELOG.md | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 409cd5e670..62ff923eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,11 @@ 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 +## [0.52.0] - 2023-02-19 ### Changed -- Textual now writes to stderr rather than stdout +- Textual now writes to stderr rather than stdout https://github.com/Textualize/textual/pull/4177 ### Added diff --git a/pyproject.toml b/pyproject.toml index 429bfa777a..e469f15794 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.51.0" +version = "0.52.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From 89d5d60ab75cd623280446f3d06b2232797e6b8d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 20 Feb 2024 09:05:40 +0000 Subject: [PATCH 34/41] Correct the animation level check in LoadingIndicator See #4188 --- src/textual/widgets/_loading_indicator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py index a826c85f85..24bc3cf2de 100644 --- a/src/textual/widgets/_loading_indicator.py +++ b/src/textual/widgets/_loading_indicator.py @@ -54,7 +54,7 @@ def _on_mount(self, _: Mount) -> None: self.auto_refresh = 1 / 16 def render(self) -> RenderableType: - if self.app.animation_level is "none": + if self.app.animation_level == "none": return Text("Loading...") elapsed = time() - self._start_time From 79cbb5019536ed4d02c80eeebb6ca8530c7ab0f8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 20 Feb 2024 09:07:25 +0000 Subject: [PATCH 35/41] Update the ChangeLog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ff923eb5..e591e95d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +### Fixed + +- Fixed the check for animation level in `LoadingIndicator` https://github.com/Textualize/textual/issues/4188 + ## [0.52.0] - 2023-02-19 ### Changed From ff4522a319444b417b6771f0fdaeac723cdf4b1a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Feb 2024 10:38:24 +0000 Subject: [PATCH 36/41] bump --- CHANGELOG.md | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e591e95d44..61689b4643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +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 +## [0.52.1] - 2024-02-20 ### Fixed - Fixed the check for animation level in `LoadingIndicator` https://github.com/Textualize/textual/issues/4188 -## [0.52.0] - 2023-02-19 +## [0.52.0] - 2024-02-19 ### Changed diff --git a/pyproject.toml b/pyproject.toml index e469f15794..c495d3661a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.52.0" +version = "0.52.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From 20a8d5d5085e68b9c14b79b80caed0b54a6a55e2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Feb 2024 11:56:52 +0000 Subject: [PATCH 37/41] docs --- docs/api/constants.md | 2 +- docs/blog/posts/remote-memray.md | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 docs/blog/posts/remote-memray.md diff --git a/docs/api/constants.md b/docs/api/constants.md index f4d97e8dfd..88aa35b2f9 100644 --- a/docs/api/constants.md +++ b/docs/api/constants.md @@ -1 +1 @@ -::: textuals.constants +::: textual.constants diff --git a/docs/blog/posts/remote-memray.md b/docs/blog/posts/remote-memray.md new file mode 100644 index 0000000000..dbc0e8410d --- /dev/null +++ b/docs/blog/posts/remote-memray.md @@ -0,0 +1,44 @@ +--- +draft: false +date: 2024-02-20 +categories: + - DevLog +authors: + - willmcgugan +--- + +# Remote memory profiling with Memray + +[Memray](https://github.com/bloomberg/memray) is a memory profiler for Python, built by some very smart devs at Bloomberg. +It is a fantastic tool to identify memory leaks in your code or other libraries (down to the C level)! + +They recently added a [Textual](https://github.com/textualize/textual/) interface which looks amazing, and lets you monitor your process right from the terminal: + +![Memray](https://raw.githubusercontent.com/bloomberg/memray/main/docs/_static/images/live_animated.webp) + + + +You would typically run this locally, or over a ssh session, but it is also possible to serve the interface over the web with the help of [textual-web](https://github.com/Textualize/textual-web). +I'm not sure if event the Memray devs themselves are aware of this, but here's how. + +First install Textual web (ideally with pipx) alongside Memray: + +```bash +pipx install textual-web +``` + +Now you can serve Memray with the following command (replace the text in quotes with your Memray options): + +```bash +textual-web -r "memray run --live -m http.server" +``` + +This will return a URL, where you can access the Memray app from anywhere. +Here's a quick video of that in action: + + + +## Found this interesting? + + +Join our [Discord server](https://discord.gg/Enf6Z3qhVr) if you want to discuss this post with the Textual devs or community. From 82d3934005e307abf8f8f9cd2710e2f6d6d630fa Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Feb 2024 13:14:23 +0000 Subject: [PATCH 38/41] typo --- docs/blog/posts/remote-memray.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/remote-memray.md b/docs/blog/posts/remote-memray.md index dbc0e8410d..399d36160e 100644 --- a/docs/blog/posts/remote-memray.md +++ b/docs/blog/posts/remote-memray.md @@ -19,7 +19,7 @@ They recently added a [Textual](https://github.com/textualize/textual/) interfac You would typically run this locally, or over a ssh session, but it is also possible to serve the interface over the web with the help of [textual-web](https://github.com/Textualize/textual-web). -I'm not sure if event the Memray devs themselves are aware of this, but here's how. +I'm not sure if even the Memray devs themselves are aware of this, but here's how. First install Textual web (ideally with pipx) alongside Memray: From 266154d2ef49853ef9719d69e682ec54e8f5ed3f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Feb 2024 14:24:22 +0000 Subject: [PATCH 39/41] screenshots --- docs/images/screenshots/frogmouth.svg | 232 ++++++++++++++++++++++++++ docs/images/screenshots/harlequin.svg | 36 ++++ docs/images/screenshots/memray.svg | 200 ++++++++++++++++++++++ docs/images/screenshots/toolong.svg | 199 ++++++++++++++++++++++ docs/index.md | 49 +++++- 5 files changed, 708 insertions(+), 8 deletions(-) create mode 100644 docs/images/screenshots/frogmouth.svg create mode 100644 docs/images/screenshots/harlequin.svg create mode 100644 docs/images/screenshots/memray.svg create mode 100644 docs/images/screenshots/toolong.svg diff --git a/docs/images/screenshots/frogmouth.svg b/docs/images/screenshots/frogmouth.svg new file mode 100644 index 0000000000..5084919b2b --- /dev/null +++ b/docs/images/screenshots/frogmouth.svg @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Frogmouth + + + + + + + + + + +https://raw.githubusercontent.com/textualize/frogmouth/main/README.md + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🖼  DiscordContentsLocalBookmarksHistory +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▼ Ⅰ Frogmouth +Frogmouth├── Ⅱ Screenshots +├── Ⅱ Compatibility +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔├── Ⅱ Installing +Frogmouth is a Markdown viewer / browser for your terminal, ├── Ⅱ Running +built with Textual.├── Ⅱ Features +└── Ⅱ Follow this project +Frogmouth can open *.md files locally or via a URL. There is a  +familiar browser-like navigation stack, history, bookmarks, and +table of contents.▅▅ + +A quick video tour of Frogmouth. + +https://user-images.githubusercontent.com/554369/235305502-2699 +a70e-c9a6-495e-990e-67606d84bbfa.mp4 + +(thanks Screen Studio) + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +                        Screenshots                         + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + +                       Compatibility                        + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Frogmouth runs on Linux, macOS, and Windows. Frogmouth requires +Python 3.8 or above. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + F1  Help  F2  About  CTRL+N  Navigation  CTRL+Q  Quit  + + + diff --git a/docs/images/screenshots/harlequin.svg b/docs/images/screenshots/harlequin.svg new file mode 100644 index 0000000000..651b094f2e --- /dev/null +++ b/docs/images/screenshots/harlequin.svg @@ -0,0 +1,36 @@ +Harlequin Data Catalog ───────────── Query Editor ───────────────────────────────────────────────────────────────────────── +▼ f1 db 1  select +└─ ▼ main sch 2  drivers.surname,                                          +├─ ▶ circuits t 3  drivers.forename,                                         +├─ ▶ constructor_result 4  drivers.nationality,                                      +├─ ▶ constructor_standi 5  avg(driver_standings.position)asavg_standing,           +├─ ▶ constructors t 6  avg(driver_standings.points)asavg_points +├─ ▶ driver_standings t 7  fromdriver_standings +├─ ▼ drivers t 8  joindriversondriver_standings.driverid=drivers.driverid +│  ├─ code s 9  joinracesondriver_standings.raceid=races.raceid +│  ├─ dob d10  groupby123 +│  ├─ driverId ##11  orderbyavg_standing asc                                     +│  ├─ driverRef s +│  ├─ forename s +│  ├─ nationality s +│  ├─ number s +│  ├─ surname s──────────────────────────────────────────────────────────────────────────────────────── +│  └─ url sX Limit 500Run Query +├─ ▶ lap_times t Query Results (850 Records) ────────────────────────────────────────────────────────── +├─ ▶ pit_stops t surname s forename s nationality s avg_standing #.# av +├─ ▶ qualifying t Hamilton                 Lewis              British            2.66                14 +├─ ▶ races t Prost                    Alain              French             3.51                33 +├─ ▶ results t Stewart                  Jackie             British            3.78                24 +├─ ▶ seasons t Schumacher               Michael            German             4.33                46 +├─ ▶ sprint_results t Verstappen               Max                Dutch              5.09                12 +├─ ▶ status t Fangio                   Juan               Argentine          5.22                16 +└─ ▶ tbl1 t Pablo Montoya            Juan               Colombian          5.25                27 + Farina                   Nino               Italian            5.27                11 + Hulme                    Denny              New Zealander      5.34                14 + Fagioli                  Luigi              Italian            5.67                9. + Clark                    Jim                British            5.81                17 + Vettel                   Sebastian          German             5.84                10 + Senna                    Ayrton             Brazilian          5.92                31 + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + CTRL+Q  Quit  F1  Help  diff --git a/docs/images/screenshots/memray.svg b/docs/images/screenshots/memray.svg new file mode 100644 index 0000000000..994b110748 --- /dev/null +++ b/docs/images/screenshots/memray.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TUIApp + + + + + + + + + + Memray live tracking      Tue Feb 20 13:53:11 2024 + (∩`-´)⊃━☆゚.*・。゚  Heap Usage ───────────────────── +PID: 77542CMD: memray run --live -m http.server                               ███ +TID: 0x1Thread 1 of 1                               ███ +Samples: 6Duration: 6.1 seconds                               ███ +                               ███ +── 1.501MB (100% of 1.501MB max)  + +                      Location                      Total Bytes% TotalOwn Bytes% OwnAllocations + _run_tracker                                           1.440MB 95.94%  1.111KB0.07%        440 memray.comman + _run_module_code                                       1.381MB 91.99%   0.000B0.00%        388 <frozen runpy + _find_and_load                                         1.364MB 90.86% 960.000B0.06%        361 <frozen impor + _load_unlocked                                         1.360MB 90.62%   0.000B0.00%        355 <frozen impor▄▄ + exec_module                                            1.355MB 90.28%  1.225KB0.08%        351 <frozen impor + run_module                                             1.351MB 90.00%  1.273KB0.08%        354 <frozen runpy + _run_code                                              1.334MB 88.90% 890.000B0.06%        341 <frozen runpy + _call_with_frames_removed                              1.298MB 86.49%   0.000B0.00%        283 <frozen impor + get_code                                               1.168MB 77.80%   0.000B0.00%        185 <frozen impor + <module>                                               1.095MB 72.96%  1.688KB0.11%         95 http.server   + _find_and_load_unlocked                               59.031KB  3.84%   1.000B0.00%         40 <frozen impor + test                                                  42.097KB  2.74%   0.000B0.00%         27 http.server   + __init__                                              41.565KB  2.70%   0.000B0.00%         20 socketserver  + getfqdn                                               40.933KB  2.66%  2.135KB0.14%         18 socket        + server_bind                                           40.933KB  2.66%   0.000B0.00%         18 http.server   + search_function                                       38.798KB  2.52%   0.000B0.00%         16 encodings     + _handle_fromlist                                      29.723KB  1.93%   0.000B0.00%         33 <frozen impor + <module>                                              24.617KB  1.60%  1.688KB0.11%          6 encodings.idn + _compile                                              23.629KB  1.54%   0.000B0.00%         11 re            + + Q  Quit  <  Previous Thread  >  Next Thread  T  Sort by Total  O  Sort by Own  A  Sort by Allocations  SPACE  Pause  + + + diff --git a/docs/images/screenshots/toolong.svg b/docs/images/screenshots/toolong.svg new file mode 100644 index 0000000000..8ac6c95d96 --- /dev/null +++ b/docs/images/screenshots/toolong.svg @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UI + + + + + + + + + + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +feedsX Case sensitiveX Regex +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +162.71.236.120 - - [29/Jan/2024:13:34:58 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"-""Net +52.70.240.171 - - [29/Jan/2024:13:35:33 +0000]"GET /2007/07/10/postmarkup-105/ HTTP/1.1"3010 +121.137.55.45 - - [29/Jan/2024:13:36:19 +0000]"GET /blog/rootblog/feeds/posts/ HTTP/1.1"20010 +98.207.26.211 - - [29/Jan/2024:13:36:37 +0000]"GET /feeds/posts HTTP/1.1"3070"-""Mozilla/5. +98.207.26.211 - - [29/Jan/2024:13:36:42 +0000]"GET /feeds/posts/ HTTP/1.1"20098063"-""Mozil +18.183.222.19 - - [29/Jan/2024:13:37:44 +0000]"GET /blog/rootblog/feeds/posts/ HTTP/1.1"20010 +66.249.64.164 - - [29/Jan/2024:13:37:46 +0000]"GET /blog/tech/post/a-texture-mapped-spinning-3d +116.203.207.165 - - [29/Jan/2024:13:37:55 +0000]"GET /blog/tech/feeds/posts/ HTTP/1.1"2001182 +128.65.195.158 - - [29/Jan/2024:13:38:44 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"https:/ +128.65.195.158 - - [29/Jan/2024:13:38:46 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"https:/ +51.222.253.12 - - [29/Jan/2024:13:41:17 +0000]"GET /blog/tech/post/css-in-the-terminal-with-pyt +154.159.237.77 - - [29/Jan/2024:13:42:28 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"-""Moz +92.247.181.10 - - [29/Jan/2024:13:43:23 +0000]"GET /feed/ HTTP/1.1"200107059"https://www.wil +134.209.40.52 - - [29/Jan/2024:13:43:41 +0000]"GET /blog/tech/feeds/posts/ HTTP/1.1"200118238 +192.3.134.205 - - [29/Jan/2024:13:43:55 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"-""Mozi +174.136.108.22 - - [29/Jan/2024:13:44:42 +0000]"GET /feeds/posts/ HTTP/1.1"200107059"-""Tin +64.71.157.117 - - [29/Jan/2024:13:45:16 +0000]"GET /feed/ HTTP/1.1"200107059"-""Feedbin fee +121.137.55.45 - - [29/Jan/2024:13:45:19 +0000]"GET /blog/rootblog/feeds/posts/ HTTP/1.1"20010 +216.244.66.233 - - [29/Jan/2024:13:45:22 +0000]"GET /robots.txt HTTP/1.1"200132"-""Mozilla/ +78.82.5.250 - - [29/Jan/2024:13:45:29 +0000]"GET /blog/tech/post/real-working-hyperlinks-in-the +78.82.5.250 - - [29/Jan/2024:13:45:30 +0000]"GET /favicon.ico HTTP/1.1"2005694"https://www.w▁▁ +46.244.252.112 - - [29/Jan/2024:13:46:44 +0000]"GET /blog/tech/feeds/posts/ HTTP/1.1"20011823▁▁ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +f1 Help^t Tail^l Line nos.^g Go to Next PreviousTAIL29/01/2024 13:34:58 • 2540 + + + diff --git a/docs/index.md b/docs/index.md index 1c06781407..380801eb75 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,20 +77,53 @@ Build sophisticated user interfaces with a simple Python API. Run your apps in t +--- + -```{.textual path="examples/calculator.py" columns=100 lines=41 press="3,.,1,4,5,9,2,wait:400"} -``` +--- -```{.textual path="examples/pride.py"} -``` + -```{.textual path="docs/examples/tutorial/stopwatch.py" columns="100" lines="30" press="d,tab,enter"} -``` +--- + + + +--- + + + + +--- + + +![Elia](https://private-user-images.githubusercontent.com/49741340/256117145-80453ed8-ec94-4095-b721-89d32d9fc327.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MDg0MzkxNTMsIm5iZiI6MTcwODQzODg1MywicGF0aCI6Ii80OTc0MTM0MC8yNTYxMTcxNDUtODA0NTNlZDgtZWM5NC00MDk1LWI3MjEtODlkMzJkOWZjMzI3LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDAyMjAlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwMjIwVDE0MjA1M1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTAxYmZkMjBmYjViMmZmYjBkM2QzNjI3NGJlZmQ1ODQwODJhZDg5N2E2NDZkZjU4NDI2ZTJmNjQ5NzE0M2Q1MzgmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.QyjiBTcjQ2Ajh1BKtMr50ErTQzAtYVd-14U3-OEB_18) -```{.textual path="docs/examples/guide/layout/combining_layouts.py" columns="100", lines="30"} + + +--- + +```{.textual path="examples/calculator.py" columns=100 lines=41 press="3,.,1,4,5,9,2,wait:400"} ``` -```{.textual path="docs/examples/app/widgets01.py"} +--- + +```{.textual path="examples/pride.py"} ``` From c586b199cffad81a794a9adecfb473bdda1c1fc7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Feb 2024 14:28:45 +0000 Subject: [PATCH 40/41] remove elia --- docs/index.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/index.md b/docs/index.md index 380801eb75..e4faac4ee7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -110,14 +110,7 @@ Build sophisticated user interfaces with a simple Python API. Run your apps in t ---- - - -![Elia](https://private-user-images.githubusercontent.com/49741340/256117145-80453ed8-ec94-4095-b721-89d32d9fc327.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MDg0MzkxNTMsIm5iZiI6MTcwODQzODg1MywicGF0aCI6Ii80OTc0MTM0MC8yNTYxMTcxNDUtODA0NTNlZDgtZWM5NC00MDk1LWI3MjEtODlkMzJkOWZjMzI3LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDAyMjAlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwMjIwVDE0MjA1M1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTAxYmZkMjBmYjViMmZmYjBkM2QzNjI3NGJlZmQ1ODQwODJhZDg5N2E2NDZkZjU4NDI2ZTJmNjQ5NzE0M2Q1MzgmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.QyjiBTcjQ2Ajh1BKtMr50ErTQzAtYVd-14U3-OEB_18) - - - --- ```{.textual path="examples/calculator.py" columns=100 lines=41 press="3,.,1,4,5,9,2,wait:400"} From 741a42ec9dcefd204f7f404cc3ce639aab1082b1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 20 Feb 2024 14:46:37 +0000 Subject: [PATCH 41/41] Add dolphie --- docs/index.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/index.md b/docs/index.md index e4faac4ee7..d80a22cd81 100644 --- a/docs/index.md +++ b/docs/index.md @@ -104,6 +104,15 @@ Build sophisticated user interfaces with a simple Python API. Run your apps in t --- + + +![Dolphie](https://www.textualize.io/static/img/dolphie.png) + + + + +--- +