Skip to content

Commit dfeea7d

Browse files
Merge branch 'main' into add-widget-lock
2 parents dded0ec + e849168 commit dfeea7d

27 files changed

+771
-52
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## Unreleased
99

10+
### Changed
11+
12+
- Textual now writes to stderr rather than stdout
13+
1014
### Added
1115

1216
- Added an `asyncio` lock attribute `Widget.lock` to be used to synchronize widget state https://github.com/Textualize/textual/issues/4134
17+
- Added support for environment variable `TEXTUAL_ANIMATIONS` to control what animations Textual displays https://github.com/Textualize/textual/pull/4062
18+
- Add attribute `App.animation_level` to control whether animations on that app run or not https://github.com/Textualize/textual/pull/4062
1319

1420
## [0.51.0] - 2024-02-15
1521

@@ -127,6 +133,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
127133
- Added `App.suspend` https://github.com/Textualize/textual/pull/4064
128134
- Added `App.action_suspend_process` https://github.com/Textualize/textual/pull/4064
129135

136+
130137
### Fixed
131138

132139
- Parameter `animate` from `DataTable.move_cursor` was being ignored https://github.com/Textualize/textual/issues/3840

docs/api/constants.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: textuals.constants

mkdocs-nav.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ nav:
178178
- "api/cache.md"
179179
- "api/color.md"
180180
- "api/command.md"
181+
- "api/constants.md"
181182
- "api/containers.md"
182183
- "api/content_switcher.md"
183184
- "api/coordinate.md"

src/textual/__init__.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,21 @@
2727
LogCallable: TypeAlias = "Callable"
2828

2929

30-
def __getattr__(name: str) -> str:
31-
"""Lazily get the version."""
32-
if name == "__version__":
33-
from importlib.metadata import version
30+
if TYPE_CHECKING:
31+
32+
from importlib.metadata import version
33+
34+
__version__ = version("textual")
35+
36+
else:
37+
38+
def __getattr__(name: str) -> str:
39+
"""Lazily get the version."""
40+
if name == "__version__":
41+
from importlib.metadata import version
3442

35-
return version("textual")
36-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
43+
return version("textual")
44+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
3745

3846

3947
class LoggerError(Exception):

src/textual/_animator.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from . import _time
1212
from ._callback import invoke
1313
from ._easing import DEFAULT_EASING, EASING
14-
from ._types import CallbackType
14+
from ._types import AnimationLevel, CallbackType
1515
from .timer import Timer
1616

1717
if TYPE_CHECKING:
@@ -53,7 +53,11 @@ class Animation(ABC):
5353
"""Callback to run after animation completes"""
5454

5555
@abstractmethod
56-
def __call__(self, time: float) -> bool: # pragma: no cover
56+
def __call__(
57+
self,
58+
time: float,
59+
app_animation_level: AnimationLevel = "full",
60+
) -> bool: # pragma: no cover
5761
"""Call the animation, return a boolean indicating whether animation is in-progress or complete.
5862
5963
Args:
@@ -93,9 +97,18 @@ class SimpleAnimation(Animation):
9397
final_value: object
9498
easing: EasingFunction
9599
on_complete: CallbackType | None = None
100+
level: AnimationLevel = "full"
101+
"""Minimum level required for the animation to take place (inclusive)."""
96102

97-
def __call__(self, time: float) -> bool:
98-
if self.duration == 0:
103+
def __call__(
104+
self, time: float, app_animation_level: AnimationLevel = "full"
105+
) -> bool:
106+
if (
107+
self.duration == 0
108+
or app_animation_level == "none"
109+
or app_animation_level == "basic"
110+
and self.level == "full"
111+
):
99112
setattr(self.obj, self.attribute, self.final_value)
100113
return True
101114

@@ -170,6 +183,7 @@ def __call__(
170183
delay: float = 0.0,
171184
easing: EasingFunction | str = DEFAULT_EASING,
172185
on_complete: CallbackType | None = None,
186+
level: AnimationLevel = "full",
173187
) -> None:
174188
"""Animate an attribute.
175189
@@ -182,6 +196,7 @@ def __call__(
182196
delay: A delay (in seconds) before the animation starts.
183197
easing: An easing method.
184198
on_complete: A callable to invoke when the animation is finished.
199+
level: Minimum level required for the animation to take place (inclusive).
185200
"""
186201
start_value = getattr(self._obj, attribute)
187202
if isinstance(value, str) and hasattr(start_value, "parse"):
@@ -200,6 +215,7 @@ def __call__(
200215
delay=delay,
201216
easing=easing_function,
202217
on_complete=on_complete,
218+
level=level,
203219
)
204220

205221

@@ -284,6 +300,7 @@ def animate(
284300
easing: EasingFunction | str = DEFAULT_EASING,
285301
delay: float = 0.0,
286302
on_complete: CallbackType | None = None,
303+
level: AnimationLevel = "full",
287304
) -> None:
288305
"""Animate an attribute to a new value.
289306
@@ -297,6 +314,7 @@ def animate(
297314
easing: An easing function.
298315
delay: Number of seconds to delay the start of the animation by.
299316
on_complete: Callback to run after the animation completes.
317+
level: Minimum level required for the animation to take place (inclusive).
300318
"""
301319
animate_callback = partial(
302320
self._animate,
@@ -308,6 +326,7 @@ def animate(
308326
speed=speed,
309327
easing=easing,
310328
on_complete=on_complete,
329+
level=level,
311330
)
312331
if delay:
313332
self._complete_event.clear()
@@ -328,7 +347,8 @@ def _animate(
328347
speed: float | None = None,
329348
easing: EasingFunction | str = DEFAULT_EASING,
330349
on_complete: CallbackType | None = None,
331-
):
350+
level: AnimationLevel = "full",
351+
) -> None:
332352
"""Animate an attribute to a new value.
333353
334354
Args:
@@ -340,6 +360,7 @@ def _animate(
340360
speed: The speed of the animation.
341361
easing: An easing function.
342362
on_complete: Callback to run after the animation completes.
363+
level: Minimum level required for the animation to take place (inclusive).
343364
"""
344365
if not hasattr(obj, attribute):
345366
raise AttributeError(
@@ -373,6 +394,7 @@ def _animate(
373394
speed=speed,
374395
easing=easing_function,
375396
on_complete=on_complete,
397+
level=level,
376398
)
377399

378400
if animation is None:
@@ -414,6 +436,7 @@ def _animate(
414436
if on_complete is not None
415437
else None
416438
),
439+
level=level,
417440
)
418441
assert animation is not None, "animation expected to be non-None"
419442

@@ -521,11 +544,12 @@ def __call__(self) -> None:
521544
if not self._scheduled:
522545
self._complete_event.set()
523546
else:
547+
app_animation_level = self.app.animation_level
524548
animation_time = self._get_time()
525549
animation_keys = list(self._animations.keys())
526550
for animation_key in animation_keys:
527551
animation = self._animations[animation_key]
528-
animation_complete = animation(animation_time)
552+
animation_complete = animation(animation_time, app_animation_level)
529553
if animation_complete:
530554
del self._animations[animation_key]
531555
if animation.on_complete is not None:

src/textual/_context.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ class NoActiveAppError(RuntimeError):
1414
"""Runtime error raised if we try to retrieve the active app when there is none."""
1515

1616

17-
active_app: ContextVar["App"] = ContextVar("active_app")
17+
active_app: ContextVar["App[object]"] = ContextVar("active_app")
1818
active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump")
1919
prevent_message_types_stack: ContextVar[list[set[type[Message]]]] = ContextVar(
2020
"prevent_message_types_stack"
2121
)
22-
visible_screen_stack: ContextVar[list[Screen]] = ContextVar("visible_screen_stack")
22+
visible_screen_stack: ContextVar[list[Screen[object]]] = ContextVar(
23+
"visible_screen_stack"
24+
)
2325
"""A stack of visible screens (with background alpha < 1), used in the screen render process."""
2426
message_hook: ContextVar[Callable[[Message], None]] = ContextVar("message_hook")
2527
"""A callable that accepts a message. Used by App.run_test."""

src/textual/_types.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Union
1+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Literal, Union
22

33
from typing_extensions import Protocol
44

@@ -52,3 +52,6 @@ class UnusedParameter:
5252
WatchCallbackNoArgsType,
5353
]
5454
"""Type used for callbacks passed to the `watch` method of widgets."""
55+
56+
AnimationLevel = Literal["none", "basic", "full"]
57+
"""The levels that the [`TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS] env var can be set to."""

src/textual/app.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
from ._context import message_hook as message_hook_context_var
7979
from ._event_broker import NoHandler, extract_handler_actions
8080
from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative
81+
from ._types import AnimationLevel
8182
from ._wait import wait_for_idle
8283
from ._worker_manager import WorkerManager
8384
from .actions import ActionParseResult, SkipAction
@@ -614,6 +615,12 @@ def __init__(
614615
self.set_class(self.dark, "-dark-mode")
615616
self.set_class(not self.dark, "-light-mode")
616617

618+
self.animation_level: AnimationLevel = constants.TEXTUAL_ANIMATIONS
619+
"""Determines what type of animations the app will display.
620+
621+
See [`textual.constants.TEXTUAL_ANIMATIONS`][textual.constants.TEXTUAL_ANIMATIONS].
622+
"""
623+
617624
def validate_title(self, title: Any) -> str:
618625
"""Make sure the title is set to a string."""
619626
return str(title)
@@ -709,6 +716,7 @@ def animate(
709716
delay: float = 0.0,
710717
easing: EasingFunction | str = DEFAULT_EASING,
711718
on_complete: CallbackType | None = None,
719+
level: AnimationLevel = "full",
712720
) -> None:
713721
"""Animate an attribute.
714722
@@ -723,6 +731,7 @@ def animate(
723731
delay: A delay (in seconds) before the animation starts.
724732
easing: An easing method.
725733
on_complete: A callable to invoke when the animation is finished.
734+
level: Minimum level required for the animation to take place (inclusive).
726735
"""
727736
self._animate(
728737
attribute,
@@ -733,6 +742,7 @@ def animate(
733742
delay=delay,
734743
easing=easing,
735744
on_complete=on_complete,
745+
level=level,
736746
)
737747

738748
async def stop_animation(self, attribute: str, complete: bool = True) -> None:

src/textual/constants.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
from __future__ import annotations
66

77
import os
8+
from typing import get_args
89

9-
from typing_extensions import Final
10+
from typing_extensions import Final, TypeGuard
11+
12+
from ._types import AnimationLevel
1013

1114
get_environ = os.environ.get
1215

1316

14-
def get_environ_bool(name: str) -> bool:
17+
def _get_environ_bool(name: str) -> bool:
1518
"""Check an environment variable switch.
1619
1720
Args:
@@ -24,7 +27,7 @@ def get_environ_bool(name: str) -> bool:
2427
return has_environ
2528

2629

27-
def get_environ_int(name: str, default: int) -> int:
30+
def _get_environ_int(name: str, default: int) -> int:
2831
"""Retrieves an integer environment variable.
2932
3033
Args:
@@ -44,7 +47,34 @@ def get_environ_int(name: str, default: int) -> int:
4447
return default
4548

4649

47-
DEBUG: Final[bool] = get_environ_bool("TEXTUAL_DEBUG")
50+
def _is_valid_animation_level(value: str) -> TypeGuard[AnimationLevel]:
51+
"""Checks if a string is a valid animation level.
52+
53+
Args:
54+
value: The string to check.
55+
56+
Returns:
57+
Whether it's a valid level or not.
58+
"""
59+
return value in get_args(AnimationLevel)
60+
61+
62+
def _get_textual_animations() -> AnimationLevel:
63+
"""Get the value of the environment variable that controls textual animations.
64+
65+
The variable can be in any of the values defined by [`AnimationLevel`][textual.constants.AnimationLevel].
66+
67+
Returns:
68+
The value that the variable was set to. If the environment variable is set to an
69+
invalid value, we default to showing all animations.
70+
"""
71+
value: str = get_environ("TEXTUAL_ANIMATIONS", "FULL").lower()
72+
if _is_valid_animation_level(value):
73+
return value
74+
return "full"
75+
76+
77+
DEBUG: Final[bool] = _get_environ_bool("TEXTUAL_DEBUG")
4878
"""Enable debug mode."""
4979

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

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

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

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

71-
SHOW_RETURN: Final[bool] = get_environ_bool("TEXTUAL_SHOW_RETURN")
101+
SHOW_RETURN: Final[bool] = _get_environ_bool("TEXTUAL_SHOW_RETURN")
72102
"""Write the return value on exit."""
73103

74-
MAX_FPS: Final[int] = get_environ_int("TEXTUAL_FPS", 60)
104+
MAX_FPS: Final[int] = _get_environ_int("TEXTUAL_FPS", 60)
75105
"""Maximum frames per second for updates."""
76106

77107
COLOR_SYSTEM: Final[str | None] = get_environ("TEXTUAL_COLOR_SYSTEM", "auto")
78108
"""Force color system override"""
109+
110+
TEXTUAL_ANIMATIONS: AnimationLevel = _get_textual_animations()
111+
"""Determines whether animations run or not."""

0 commit comments

Comments
 (0)