Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for env variable TEXTUAL_ANIMATIONS #4062

Merged
merged 17 commits into from
Feb 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we need the app.show_animations. Could we pass it to __call__ ?

Alternatively, do we even need to do this logic here? The animator calls this method. Could we not perform this logic from the animator? The animator has a reference to the app.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could perform the logic there but I think it's cleaner here.
If we do it on the animator, then we also need to set the attribute to its value in the animator and I think it'll look clunky there.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may need to elaborate on "cleaner" and "clunky" which are quite subjective terms.

It does feel like it is the animators job to decide if to animate something. Perhaps an Animation object should grow a finalize method which sets the attribute to its final stage.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While addressing your other remarks I think this stopped being a problem.

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 (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this sets the value to its final position, but what is to prevent the animator calling it multiple times? Anything?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. The return True tells the animator the animation was completed, which means that the animation will have its "on complete callback" called (if there's any) and then the animator gets rid of the animation:

animation_complete = animation(animation_time)
if animation_complete:
del self._animations[animation_key]
if animation.on_complete is not None:
animation.on_complete()

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
Loading