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 9 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `Query.blur` and `Query.focus` https://github.com/Textualize/textual/pull/4012
- 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

### Fixed

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
26 changes: 24 additions & 2 deletions src/textual/_animator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@

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 .timer import Timer

if TYPE_CHECKING:
Expand Down Expand Up @@ -93,9 +95,19 @@ class SimpleAnimation(Animation):
final_value: object
easing: EasingFunction
on_complete: CallbackType | None = None
animate_on_level: AnimationsEnum = AnimationsEnum.BASIC
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
"""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
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved

def __call__(self, time: float) -> bool:
if self.duration == 0:
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 self.animate_on_level.value > self.show_animations.value
):
setattr(self.obj, self.attribute, self.final_value)
return True

Expand Down Expand Up @@ -170,6 +182,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.

Expand All @@ -182,6 +195,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"):
Expand All @@ -200,6 +214,7 @@ def __call__(
delay=delay,
easing=easing_function,
on_complete=on_complete,
animate_on_level=animate_on_level,
)


Expand Down Expand Up @@ -284,6 +299,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.

Expand All @@ -297,6 +313,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,
Expand All @@ -308,6 +325,7 @@ def animate(
speed=speed,
easing=easing,
on_complete=on_complete,
animate_on_level=animate_on_level,
)
if delay:
self._complete_event.clear()
Expand All @@ -328,7 +346,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:
Expand All @@ -340,6 +359,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(
Expand Down Expand Up @@ -373,6 +393,7 @@ def _animate(
speed=speed,
easing=easing_function,
on_complete=on_complete,
animate_on_level=animate_on_level,
)

if animation is None:
Expand Down Expand Up @@ -410,6 +431,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"

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."""
10 changes: 10 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -575,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()
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
"""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)
Expand Down Expand Up @@ -670,6 +677,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.

Expand All @@ -684,6 +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).
"""
self._animate(
attribute,
Expand All @@ -694,6 +703,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:
Expand Down
45 changes: 38 additions & 7 deletions src/textual/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,31 @@

from __future__ import annotations

import enum
import os

from typing_extensions import Final

get_environ = os.environ.get


def get_environ_bool(name: str) -> bool:
class AnimationsEnum(enum.Enum):
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
"""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


def _get_environ_bool(name: str) -> bool:
"""Check an environment variable switch.

Args:
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -59,19 +90,19 @@ 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")
Expand Down
16 changes: 15 additions & 1 deletion src/textual/css/scalar_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
from typing import TYPE_CHECKING

from .._animator import Animation, EasingFunction
from .._context import active_app
from .._types import CallbackType
from ..constants import AnimationsEnum
from .scalar import Scalar, ScalarOffset

if TYPE_CHECKING:
Expand All @@ -23,6 +25,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
Expand All @@ -34,6 +37,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
Expand All @@ -48,11 +52,21 @@ def __init__(
assert duration is not None, "Duration expected to be non-None"
self.duration = duration

self.show_animations = active_app.get().show_animations
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
"""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:
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 self.animate_on_level.value > self.show_animations.value
):
setattr(self.styles, self.attribute, self.final_value)
return True

Expand Down
6 changes: 6 additions & 0 deletions src/textual/css/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -396,6 +398,7 @@ def __textual_animation__(
speed=speed,
easing=easing,
on_complete=on_complete,
animate_on_level=animate_on_level,
)
return None

Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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:
Expand Down
Loading
Loading