Skip to content

Commit

Permalink
Merge pull request #4062 from Textualize/textual-animations
Browse files Browse the repository at this point in the history
Add support for env variable TEXTUAL_ANIMATIONS
  • Loading branch information
rodrigogiraoserrao authored Feb 19, 2024
2 parents 3a2e68a + 5cb2471 commit 0fabb97
Show file tree
Hide file tree
Showing 25 changed files with 736 additions and 36 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Added support for environment variable `TEXTUAL_ANIMATIONS` to control what animations Textual displays https://github.com/Textualize/textual/pull/4062
- Add attribute `App.animation_level` to control whether animations on that app run or not https://github.com/Textualize/textual/pull/4062

## [0.51.0] - 2024-02-15

### Added
Expand Down Expand Up @@ -121,6 +128,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `App.suspend` https://github.com/Textualize/textual/pull/4064
- Added `App.action_suspend_process` https://github.com/Textualize/textual/pull/4064


### Fixed

- Parameter `animate` from `DataTable.move_cursor` was being ignored https://github.com/Textualize/textual/issues/3840
Expand Down
1 change: 1 addition & 0 deletions docs/api/constants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: textuals.constants
1 change: 1 addition & 0 deletions mkdocs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 30 additions & 6 deletions src/textual/_animator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from . import _time
from ._callback import invoke
from ._easing import DEFAULT_EASING, EASING
from ._types import CallbackType
from ._types import AnimationLevel, CallbackType
from .timer import Timer

if TYPE_CHECKING:
Expand Down Expand Up @@ -53,7 +53,11 @@ class Animation(ABC):
"""Callback to run after animation completes"""

@abstractmethod
def __call__(self, time: float) -> bool: # pragma: no cover
def __call__(
self,
time: float,
app_animation_level: AnimationLevel = "full",
) -> bool: # pragma: no cover
"""Call the animation, return a boolean indicating whether animation is in-progress or complete.
Args:
Expand Down Expand Up @@ -93,9 +97,18 @@ class SimpleAnimation(Animation):
final_value: object
easing: EasingFunction
on_complete: CallbackType | None = None
level: AnimationLevel = "full"
"""Minimum level required for the animation to take place (inclusive)."""

def __call__(self, time: float) -> bool:
if self.duration == 0:
def __call__(
self, time: float, app_animation_level: AnimationLevel = "full"
) -> bool:
if (
self.duration == 0
or app_animation_level == "none"
or app_animation_level == "basic"
and self.level == "full"
):
setattr(self.obj, self.attribute, self.final_value)
return True

Expand Down Expand Up @@ -170,6 +183,7 @@ def __call__(
delay: float = 0.0,
easing: EasingFunction | str = DEFAULT_EASING,
on_complete: CallbackType | None = None,
level: AnimationLevel = "full",
) -> None:
"""Animate an attribute.
Expand All @@ -182,6 +196,7 @@ def __call__(
delay: A delay (in seconds) before the animation starts.
easing: An easing method.
on_complete: A callable to invoke when the animation is finished.
level: Minimum level required for the animation to take place (inclusive).
"""
start_value = getattr(self._obj, attribute)
if isinstance(value, str) and hasattr(start_value, "parse"):
Expand All @@ -200,6 +215,7 @@ def __call__(
delay=delay,
easing=easing_function,
on_complete=on_complete,
level=level,
)


Expand Down Expand Up @@ -284,6 +300,7 @@ def animate(
easing: EasingFunction | str = DEFAULT_EASING,
delay: float = 0.0,
on_complete: CallbackType | None = None,
level: AnimationLevel = "full",
) -> None:
"""Animate an attribute to a new value.
Expand All @@ -297,6 +314,7 @@ def animate(
easing: An easing function.
delay: Number of seconds to delay the start of the animation by.
on_complete: Callback to run after the animation completes.
level: Minimum level required for the animation to take place (inclusive).
"""
animate_callback = partial(
self._animate,
Expand All @@ -308,6 +326,7 @@ def animate(
speed=speed,
easing=easing,
on_complete=on_complete,
level=level,
)
if delay:
self._complete_event.clear()
Expand All @@ -328,7 +347,8 @@ def _animate(
speed: float | None = None,
easing: EasingFunction | str = DEFAULT_EASING,
on_complete: CallbackType | None = None,
):
level: AnimationLevel = "full",
) -> None:
"""Animate an attribute to a new value.
Args:
Expand All @@ -340,6 +360,7 @@ def _animate(
speed: The speed of the animation.
easing: An easing function.
on_complete: Callback to run after the animation completes.
level: Minimum level required for the animation to take place (inclusive).
"""
if not hasattr(obj, attribute):
raise AttributeError(
Expand Down Expand Up @@ -373,6 +394,7 @@ def _animate(
speed=speed,
easing=easing_function,
on_complete=on_complete,
level=level,
)

if animation is None:
Expand Down Expand Up @@ -414,6 +436,7 @@ def _animate(
if on_complete is not None
else None
),
level=level,
)
assert animation is not None, "animation expected to be non-None"

Expand Down Expand Up @@ -521,11 +544,12 @@ def __call__(self) -> None:
if not self._scheduled:
self._complete_event.set()
else:
app_animation_level = self.app.animation_level
animation_time = self._get_time()
animation_keys = list(self._animations.keys())
for animation_key in animation_keys:
animation = self._animations[animation_key]
animation_complete = animation(animation_time)
animation_complete = animation(animation_time, app_animation_level)
if animation_complete:
del self._animations[animation_key]
if animation.on_complete is not None:
Expand Down
6 changes: 4 additions & 2 deletions src/textual/_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
5 changes: 4 additions & 1 deletion src/textual/_types.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -52,3 +52,6 @@ class UnusedParameter:
WatchCallbackNoArgsType,
]
"""Type used for callbacks passed to the `watch` method of widgets."""

AnimationLevel = Literal["none", "basic", "full"]
"""The levels that the [`TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS] env var can be set to."""
10 changes: 10 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
from ._context import message_hook as message_hook_context_var
from ._event_broker import NoHandler, extract_handler_actions
from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative
from ._types import AnimationLevel
from ._wait import wait_for_idle
from ._worker_manager import WorkerManager
from .actions import ActionParseResult, SkipAction
Expand Down Expand Up @@ -614,6 +615,12 @@ def __init__(
self.set_class(self.dark, "-dark-mode")
self.set_class(not self.dark, "-light-mode")

self.animation_level: AnimationLevel = constants.TEXTUAL_ANIMATIONS
"""Determines what type of animations the app will display.
See [`textual.constants.TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS].
"""

def validate_title(self, title: Any) -> str:
"""Make sure the title is set to a string."""
return str(title)
Expand Down Expand Up @@ -709,6 +716,7 @@ def animate(
delay: float = 0.0,
easing: EasingFunction | str = DEFAULT_EASING,
on_complete: CallbackType | None = None,
level: AnimationLevel = "full",
) -> None:
"""Animate an attribute.
Expand All @@ -723,6 +731,7 @@ def animate(
delay: A delay (in seconds) before the animation starts.
easing: An easing method.
on_complete: A callable to invoke when the animation is finished.
level: Minimum level required for the animation to take place (inclusive).
"""
self._animate(
attribute,
Expand All @@ -733,6 +742,7 @@ def animate(
delay=delay,
easing=easing,
on_complete=on_complete,
level=level,
)

async def stop_animation(self, attribute: str, complete: bool = True) -> None:
Expand Down
49 changes: 41 additions & 8 deletions src/textual/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
from __future__ import annotations

import os
from typing import get_args

from typing_extensions import Final
from typing_extensions import Final, TypeGuard

from ._types import AnimationLevel

get_environ = os.environ.get


def get_environ_bool(name: str) -> bool:
def _get_environ_bool(name: str) -> bool:
"""Check an environment variable switch.
Args:
Expand All @@ -24,7 +27,7 @@ def get_environ_bool(name: str) -> bool:
return has_environ


def get_environ_int(name: str, default: int) -> int:
def _get_environ_int(name: str, default: int) -> int:
"""Retrieves an integer environment variable.
Args:
Expand All @@ -44,7 +47,34 @@ def get_environ_int(name: str, default: int) -> int:
return default


DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG")
def _is_valid_animation_level(value: str) -> TypeGuard[AnimationLevel]:
"""Checks if a string is a valid animation level.
Args:
value: The string to check.
Returns:
Whether it's a valid level or not.
"""
return value in get_args(AnimationLevel)


def _get_textual_animations() -> AnimationLevel:
"""Get the value of the environment variable that controls textual animations.
The variable can be in any of the values defined by [`AnimationLevel`][textual.constants.AnimationLevel].
Returns:
The value that the variable was set to. If the environment variable is set to an
invalid value, we default to showing all animations.
"""
value: str = get_environ("TEXTUAL_ANIMATIONS", "FULL").lower()
if _is_valid_animation_level(value):
return value
return "full"


DEBUG: Final[bool] = _get_environ_bool("TEXTUAL_DEBUG")
"""Enable debug mode."""

DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None)
Expand All @@ -59,20 +89,23 @@ def get_environ_int(name: str, default: int) -> int:
DEVTOOLS_HOST: Final[str] = get_environ("TEXTUAL_DEVTOOLS_HOST", "127.0.0.1")
"""The host where textual console is running."""

DEVTOOLS_PORT: Final[int] = get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081)
DEVTOOLS_PORT: Final[int] = _get_environ_int("TEXTUAL_DEVTOOLS_PORT", 8081)
"""Constant with the port that the devtools will connect to."""

SCREENSHOT_DELAY: Final[int] = get_environ_int("TEXTUAL_SCREENSHOT", -1)
SCREENSHOT_DELAY: Final[int] = _get_environ_int("TEXTUAL_SCREENSHOT", -1)
"""Seconds delay before taking screenshot."""

PRESS: Final[str] = get_environ("TEXTUAL_PRESS", "")
"""Keys to automatically press."""

SHOW_RETURN: Final[bool] = get_environ_bool("TEXTUAL_SHOW_RETURN")
SHOW_RETURN: Final[bool] = _get_environ_bool("TEXTUAL_SHOW_RETURN")
"""Write the return value on exit."""

MAX_FPS: Final[int] = get_environ_int("TEXTUAL_FPS", 60)
MAX_FPS: Final[int] = _get_environ_int("TEXTUAL_FPS", 60)
"""Maximum frames per second for updates."""

COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto")
"""Force color system override"""

TEXTUAL_ANIMATIONS: AnimationLevel = _get_textual_animations()
"""Determines whether animations run or not."""
15 changes: 12 additions & 3 deletions src/textual/css/scalar_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import TYPE_CHECKING

from .._animator import Animation, EasingFunction
from .._types import CallbackType
from .._types import AnimationLevel, CallbackType
from .scalar import Scalar, ScalarOffset

if TYPE_CHECKING:
Expand All @@ -23,6 +23,7 @@ def __init__(
speed: float | None,
easing: EasingFunction,
on_complete: CallbackType | None = None,
level: AnimationLevel = "full",
):
assert (
speed is not None or duration is not None
Expand All @@ -34,6 +35,7 @@ def __init__(
self.final_value = value
self.easing = easing
self.on_complete = on_complete
self.level = level

size = widget.outer_size
viewport = widget.app.size
Expand All @@ -48,11 +50,18 @@ def __init__(
assert duration is not None, "Duration expected to be non-None"
self.duration = duration

def __call__(self, time: float) -> bool:
def __call__(
self, time: float, app_animation_level: AnimationLevel = "full"
) -> bool:
factor = min(1.0, (time - self.start_time) / self.duration)
eased_factor = self.easing(factor)

if eased_factor >= 1:
if (
eased_factor >= 1
or app_animation_level == "none"
or app_animation_level == "basic"
and self.level == "full"
):
setattr(self.styles, self.attribute, self.final_value)
return True

Expand Down
Loading

0 comments on commit 0fabb97

Please sign in to comment.