diff --git a/CHANGELOG.md b/CHANGELOG.md index 272a9c7a54..2b6eb7ff09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,29 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `TextArea.code_editor` missing recently added attributes https://github.com/Textualize/textual/pull/4172 +## [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] - 2024-02-19 + +### Changed + +- Textual now writes to stderr rather than stdout https://github.com/Textualize/textual/pull/4177 + +### Added + +- Added an `asyncio` lock attribute `Widget.lock` to be used to synchronize widget state https://github.com/Textualize/textual/issues/4134 +- 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 +- 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/ +- 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/pull/4183 +- `Widget.batch` combines widget locking and app update batching https://github.com/Textualize/textual/pull/4183 + ## [0.51.0] - 2024-02-15 ### Added @@ -127,6 +150,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 diff --git a/docs/api/constants.md b/docs/api/constants.md new file mode 100644 index 0000000000..88aa35b2f9 --- /dev/null +++ b/docs/api/constants.md @@ -0,0 +1 @@ +::: textual.constants diff --git a/docs/blog/posts/remote-memray.md b/docs/blog/posts/remote-memray.md new file mode 100644 index 0000000000..399d36160e --- /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 even 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. 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"} 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..d80a22cd81 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,20 +77,55 @@ Build sophisticated user interfaces with a simple Python API. Run your apps in t +--- +
+ +--8<-- "docs/images/screenshots/toolong.svg" + +
-```{.textual path="examples/calculator.py" columns=100 lines=41 press="3,.,1,4,5,9,2,wait:400"} -``` +--- -```{.textual path="examples/pride.py"} -``` +
+ +--8<-- "docs/images/screenshots/frogmouth.svg" + +
+ +--- + +
+ +--8<-- "docs/images/screenshots/memray.svg" + +
+ +--- -```{.textual path="docs/examples/tutorial/stopwatch.py" columns="100" lines="30" press="d,tab,enter"} -``` + -```{.textual path="docs/examples/guide/layout/combining_layouts.py" columns="100", lines="30"} +![Dolphie](https://www.textualize.io/static/img/dolphie.png) + + + + +--- + +
+ +--8<-- "docs/images/screenshots/harlequin.svg" + +
+ + +--- + +```{.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"} ``` 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/pyproject.toml b/pyproject.toml index 429bfa777a..c495d3661a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.51.0" +version = "0.52.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 0b93010125..64fa99601b 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -27,13 +27,21 @@ LogCallable: TypeAlias = "Callable" -def __getattr__(name: str) -> str: - """Lazily get the version.""" - if name == "__version__": - from importlib.metadata import version +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}") + return version("textual") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") class LoggerError(Exception): diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 5ea15154ed..27d6c96ce5 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -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: @@ -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: @@ -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 @@ -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. @@ -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"): @@ -200,6 +215,7 @@ def __call__( delay=delay, easing=easing_function, on_complete=on_complete, + level=level, ) @@ -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. @@ -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, @@ -308,6 +326,7 @@ def animate( speed=speed, easing=easing, on_complete=on_complete, + level=level, ) if delay: self._complete_event.clear() @@ -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: @@ -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( @@ -373,6 +394,7 @@ def _animate( speed=speed, easing=easing_function, on_complete=on_complete, + level=level, ) if animation is None: @@ -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" @@ -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: 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/_types.py b/src/textual/_types.py index 75a28e7c7c..603d799f05 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 @@ -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.""" diff --git a/src/textual/app.py b/src/textual/app.py index cc8f36633f..08c4392fbe 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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 @@ -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) @@ -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. @@ -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, @@ -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: @@ -1143,7 +1153,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,7 +1168,8 @@ def save_screenshot( Returns: Filename of screenshot. """ - if filename is None: + path = path or "./" + if not filename: if time_format is None: dt = datetime.now().isoformat() else: @@ -2387,7 +2398,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 d47d0d2c15..6d0ebfe323 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -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: @@ -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: @@ -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) @@ -59,20 +89,29 @@ 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.""" +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.""" -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.""" diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index 018d28b191..bf690b8a6c 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -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: @@ -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 @@ -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 @@ -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 diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 476350648b..8bc14d49c9 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -11,7 +11,7 @@ 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 ..geometry import Offset, Spacing from ._style_properties import ( @@ -369,6 +369,7 @@ def __textual_animation__( speed: float | None, easing: EasingFunction, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> ScalarAnimation | None: if self.node is None: return None @@ -400,6 +401,7 @@ def __textual_animation__( if on_complete is not None else None ), + level=level, ) return None @@ -1142,6 +1144,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -1154,6 +1157,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). """ if self._animate is None: assert self.node is not None @@ -1168,6 +1172,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + level=level, ) def __rich_repr__(self) -> rich.repr.Result: diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 78628f6ae4..275075ec1c 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,19 @@ 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) + """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) @classmethod def _patch_iflag(cls, attrs: int) -> int: @@ -328,15 +340,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 +360,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: diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 570bd3fafa..4e418ed350 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -7,7 +7,7 @@ from rich.console import RenderableType from ._animator import EasingFunction -from ._types import CallbackType +from ._types import AnimationLevel, CallbackType from .containers import ScrollableContainer from .geometry import Region, Size @@ -119,6 +119,7 @@ def scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -131,6 +132,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. + level: Minimum level required for the animation to take place (inclusive). """ self._scroll_to( @@ -142,6 +144,7 @@ def scroll_to( easing=easing, force=force, on_complete=on_complete, + level=level, ) def refresh_line(self, y: int) -> None: diff --git a/src/textual/types.py b/src/textual/types.py index 8ae7ec846d..95f33db4c2 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, @@ -29,6 +30,7 @@ __all__ = [ "ActionParseResult", "Animatable", + "AnimationLevel", "CallbackType", "CSSPathError", "CSSPathType", diff --git a/src/textual/widget.py b/src/textual/widget.py index 85daa5025e..157e0cb930 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4,13 +4,15 @@ from __future__ import annotations -from asyncio import create_task, wait +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, @@ -48,10 +50,13 @@ 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 .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 @@ -77,6 +82,7 @@ if TYPE_CHECKING: from .app import App, ComposeResult + from .css.query import QueryType from .message_pump import MessagePump from .scrollbar import ( ScrollBar, @@ -372,6 +378,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.""" @@ -1720,6 +1734,7 @@ def animate( delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, on_complete: CallbackType | None = None, + level: AnimationLevel = "full", ) -> None: """Animate an attribute. @@ -1732,6 +1747,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). """ if self._animate is None: self._animate = self.app.animator.bind(self) @@ -1745,6 +1761,7 @@ def animate( delay=delay, easing=easing, on_complete=on_complete, + level=level, ) async def stop_animation(self, attribute: str, complete: bool = True) -> None: @@ -1891,6 +1908,7 @@ def _scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1903,6 +1921,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if the scroll position changed, otherwise `False`. @@ -1935,6 +1954,7 @@ def _scroll_to( duration=duration, easing=easing, on_complete=on_complete, + level=level, ) scrolled_x = True if maybe_scroll_y: @@ -1948,6 +1968,7 @@ def _scroll_to( duration=duration, easing=easing, on_complete=on_complete, + level=level, ) scrolled_y = True @@ -1979,6 +2000,7 @@ def scroll_to( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1991,6 +2013,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. + level: Minimum level required for the animation to take place (inclusive). Note: The call to scroll is made after the next refresh. @@ -2005,6 +2028,7 @@ def scroll_to( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_relative( @@ -2018,6 +2042,7 @@ def scroll_relative( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll relative to current position. @@ -2030,6 +2055,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( None if x is None else (self.scroll_x + x), @@ -2040,6 +2066,7 @@ def scroll_relative( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_home( @@ -2051,6 +2078,7 @@ def scroll_home( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to home position. @@ -2061,6 +2089,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. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 1.0 @@ -2073,6 +2102,7 @@ def scroll_home( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_end( @@ -2084,6 +2114,7 @@ def scroll_end( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll to the end of the container. @@ -2094,6 +2125,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. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 1.0 @@ -2115,6 +2147,7 @@ def _lazily_scroll_end() -> None: easing=easing, force=force, on_complete=on_complete, + level=level, ) self.call_after_refresh(_lazily_scroll_end) @@ -2128,6 +2161,7 @@ def scroll_left( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one cell left. @@ -2138,6 +2172,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( x=self.scroll_target_x - 1, @@ -2147,6 +2182,7 @@ def scroll_left( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_left_for_pointer( @@ -2158,6 +2194,7 @@ def _scroll_left_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll left one position, taking scroll sensitivity into account. @@ -2168,6 +2205,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2184,6 +2222,7 @@ def _scroll_left_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_right( @@ -2195,6 +2234,7 @@ def scroll_right( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one cell right. @@ -2205,6 +2245,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( x=self.scroll_target_x + 1, @@ -2214,6 +2255,7 @@ def scroll_right( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_right_for_pointer( @@ -2225,6 +2267,7 @@ def _scroll_right_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll right one position, taking scroll sensitivity into account. @@ -2235,6 +2278,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2251,6 +2295,7 @@ def _scroll_right_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_down( @@ -2262,6 +2307,7 @@ def scroll_down( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one line down. @@ -2272,6 +2318,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_target_y + 1, @@ -2281,6 +2328,7 @@ def scroll_down( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_down_for_pointer( @@ -2292,6 +2340,7 @@ def _scroll_down_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll down one position, taking scroll sensitivity into account. @@ -2302,6 +2351,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2318,6 +2368,7 @@ def _scroll_down_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_up( @@ -2329,6 +2380,7 @@ def scroll_up( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one line up. @@ -2339,6 +2391,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_target_y - 1, @@ -2348,6 +2401,7 @@ def scroll_up( easing=easing, force=force, on_complete=on_complete, + level=level, ) def _scroll_up_for_pointer( @@ -2359,6 +2413,7 @@ def _scroll_up_for_pointer( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll up one position, taking scroll sensitivity into account. @@ -2369,6 +2424,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. @@ -2385,6 +2441,7 @@ def _scroll_up_for_pointer( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_up( @@ -2396,6 +2453,7 @@ def scroll_page_up( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page up. @@ -2406,6 +2464,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y - self.container_size.height, @@ -2415,6 +2474,7 @@ def scroll_page_up( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_down( @@ -2426,6 +2486,7 @@ def scroll_page_down( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page down. @@ -2436,6 +2497,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. + level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y + self.container_size.height, @@ -2445,6 +2507,7 @@ def scroll_page_down( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_left( @@ -2456,6 +2519,7 @@ def scroll_page_left( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page left. @@ -2466,6 +2530,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. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 @@ -2477,6 +2542,7 @@ def scroll_page_left( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_page_right( @@ -2488,6 +2554,7 @@ def scroll_page_right( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll one page right. @@ -2498,6 +2565,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. + level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 @@ -2509,6 +2577,7 @@ def scroll_page_right( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_to_widget( @@ -2524,6 +2593,7 @@ def scroll_to_widget( origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> bool: """Scroll scrolling to bring a widget in to view. @@ -2537,6 +2607,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. + level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling has occurred in any descendant, otherwise `False`. @@ -2563,6 +2634,7 @@ def scroll_to_widget( origin_visible=origin_visible, force=force, on_complete=on_complete, + level=level, ) if scroll_offset: scrolled = True @@ -2597,6 +2669,7 @@ def scroll_to_region( origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> Offset: """Scrolls a given region in to view, if required. @@ -2614,6 +2687,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. + level: Minimum level required for the animation to take place (inclusive). Returns: The distance that was scrolled. @@ -2660,6 +2734,7 @@ def scroll_to_region( easing=easing, force=force, on_complete=on_complete, + level=level, ) return delta @@ -2673,6 +2748,7 @@ def scroll_visible( easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll the container to make this widget visible. @@ -2684,6 +2760,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. + level: Minimum level required for the animation to take place (inclusive). """ parent = self.parent if isinstance(parent, Widget): @@ -2697,6 +2774,7 @@ def scroll_visible( easing=easing, force=force, on_complete=on_complete, + level=level, ) def scroll_to_center( @@ -2710,6 +2788,7 @@ def scroll_to_center( force: bool = False, origin_visible: bool = True, on_complete: CallbackType | None = None, + level: AnimationLevel = "basic", ) -> None: """Scroll this widget to the center of self. @@ -2724,6 +2803,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. + level: Minimum level required for the animation to take place (inclusive). """ self.call_after_refresh( @@ -2737,6 +2817,7 @@ def scroll_to_center( center=True, origin_visible=origin_visible, on_complete=on_complete, + level=level, ) def can_view(self, widget: Widget) -> bool: @@ -3215,15 +3296,42 @@ 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] = "*") -> AwaitRemove: + """Remove the immediate children of this Widget from the DOM. + + Args: + 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. """ - await_remove = self.app._remove_nodes(list(self.children), 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 + 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. diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 83a3237b2d..ca88e11323 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -242,6 +242,9 @@ async def _on_click(self, event: events.Click) -> None: def press(self) -> Self: """Respond to a button press. + Args: + level: Minimum level required for the animation to take place (inclusive). + Returns: The button instance.""" if self.disabled or not self.display: @@ -253,7 +256,11 @@ def press(self) -> Self: return self def _start_active_affect(self) -> None: - """Start a small animation to show the button was clicked.""" + """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: self.add_class("-active") self.set_timer( diff --git a/src/textual/widgets/_loading_indicator.py b/src/textual/widgets/_loading_indicator.py index e7cc4abb47..24bc3cf2de 100644 --- a/src/textual/widgets/_loading_indicator.py +++ b/src/textual/widgets/_loading_indicator.py @@ -54,6 +54,9 @@ def _on_mount(self, _: Mount) -> None: self.auto_refresh = 1 / 16 def render(self) -> RenderableType: + if self.app.animation_level == "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 6127f48f4f..fea9c2fba2 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -105,14 +105,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 self.app.animation_level == "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 2b8d4ccc39..96cce3eeb8 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -130,7 +130,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, + level="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 276f7e58ec..adf49873e1 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -591,7 +591,10 @@ 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, + ) -> None: """Move the underline bar to under the active tab. Args: @@ -608,7 +611,8 @@ 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: + # 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.""" @@ -621,8 +625,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, + level="basic", + ) + underline.animate( + "highlight_end", + end, + duration=0.3, + level="basic", + ) self.set_timer(0.02, lambda: self.call_after_refresh(animate_underline)) else: @@ -644,7 +658,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. @@ -700,7 +713,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.""" 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) diff --git a/tests/animations/test_disabling_animations.py b/tests/animations/test_disabling_animations.py new file mode 100644 index 0000000000..2880262025 --- /dev/null +++ b/tests/animations/test_disabling_animations.py @@ -0,0 +1,163 @@ +""" +Test that generic animations can be disabled. +""" + +from textual.app import App, ComposeResult +from textual.color import Color +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.animation_level = "full" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.styles.animate("background", "blue", duration=1) + assert len(animator._animations) > 0 # Sanity check. + # Freeze time around the animation midpoint. + animator._get_time = lambda *_: 0.5 + # Move to the next frame. + animator() + # The animation shouldn't have completed. + assert label.styles.background != Color.parse("red") + assert label.styles.background != Color.parse("blue") + + +async def test_style_animations_via_animate_are_disabled_on_basic() -> None: + app = SingleLabelApp() + app.animation_level = "basic" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.styles.animate("background", "blue", duration=1) + 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. + animator() + # The animation should have completed. + assert label.styles.background == Color.parse("blue") + + +async def test_style_animations_via_animate_are_disabled_on_none() -> None: + app = SingleLabelApp() + app.animation_level = "none" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.styles.animate("background", "blue", duration=1) + 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. + animator() + # The animation should have completed. + 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.animation_level = "full" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.add_class("blue-bg") + assert len(animator._animations) > 0 # Sanity check. + # Freeze time in the middle of the animation. + animator._get_time = lambda *_: 0.5 + animator() + # The animation should be undergoing. + assert label.styles.background != Color.parse("red") + assert label.styles.background != Color.parse("blue") + + +async def test_style_animations_via_transition_are_disabled_on_basic() -> None: + app = LabelWithTransitionsApp() + app.animation_level = "basic" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.add_class("blue-bg") + assert len(animator._animations) > 0 # Sanity check. + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + animator() + # The animation should have completed. + assert label.styles.background == Color.parse("blue") + + +async def test_style_animations_via_transition_are_disabled_on_none() -> None: + app = LabelWithTransitionsApp() + app.animation_level = "none" + + async with app.run_test(): + label = app.query_one(Label) + # Sanity check. + assert label.styles.background == Color.parse("red") + animator = app.animator + # Free time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + label.add_class("blue-bg") + assert len(animator._animations) > 0 # Sanity check. + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + animator() + # The animation should have completed. + assert label.styles.background == Color.parse("blue") diff --git a/tests/animations/test_environment_variable.py b/tests/animations/test_environment_variable.py new file mode 100644 index 0000000000..49359d6a75 --- /dev/null +++ b/tests/animations/test_environment_variable.py @@ -0,0 +1,32 @@ +import pytest + +from textual import constants +from textual.app import App +from textual.constants import _get_textual_animations + + +@pytest.mark.parametrize( + ["env_variable", "value"], + [ + ("", "full"), # default + ("FULL", "full"), + ("BASIC", "basic"), + ("NONE", "none"), + ("garbanzo beans", "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( + ["value"], + [("full",), ("basic",), ("none",)], +) +def test_app_show_animations(monkeypatch, value): # type: ignore + """Test that the app gets the value of `show_animations` correctly.""" + monkeypatch.setattr(constants, "TEXTUAL_ANIMATIONS", value) + app = App() + assert app.animation_level == value diff --git a/tests/animations/test_loading_indicator_animation.py b/tests/animations/test_loading_indicator_animation.py new file mode 100644 index 0000000000..3f1df80a55 --- /dev/null +++ b/tests/animations/test_loading_indicator_animation.py @@ -0,0 +1,43 @@ +""" +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.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.animation_level = "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.animation_level = "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.animation_level = "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..82e2add599 --- /dev/null +++ b/tests/animations/test_progress_bar_animation.py @@ -0,0 +1,47 @@ +""" +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.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.animation_level = "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.animation_level = "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.animation_level = "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..172dc09c61 --- /dev/null +++ b/tests/animations/test_scrolling_animation.py @@ -0,0 +1,69 @@ +""" +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.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.animation_level = "full" + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + 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() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert animator.is_being_animated(vertical_scroll, "scroll_y") + + +async def test_scrolling_animates_on_basic() -> None: + app = TallApp() + app.animation_level = "basic" + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + 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() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert animator.is_being_animated(vertical_scroll, "scroll_y") + + +async def test_scrolling_does_not_animate_on_none() -> None: + app = TallApp() + app.animation_level = "none" + + async with app.run_test() as pilot: + vertical_scroll = app.query_one(VerticalScroll) + 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() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + 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 new file mode 100644 index 0000000000..333e6903f3 --- /dev/null +++ b/tests/animations/test_switch_animation.py @@ -0,0 +1,69 @@ +""" +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.widgets import Switch + + +class SwitchApp(App[None]): + def compose(self) -> ComposeResult: + yield Switch() + + +async def test_switch_animates_on_full() -> None: + app = SwitchApp() + app.animation_level = "full" + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + animator = app.animator + # 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. + 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.animation_level = "basic" + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + animator = app.animator + # 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. + 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.animation_level = "none" + + async with app.run_test() as pilot: + switch = app.query_one(Switch) + animator = app.animator + # 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. + 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 new file mode 100644 index 0000000000..05e83e9e5d --- /dev/null +++ b/tests/animations/test_tabs_underline_animation.py @@ -0,0 +1,75 @@ +""" +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.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.animation_level = "full" + + async with app.run_test() as pilot: + underline = app.query_one(Underline) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + app.query_one(Tabs).action_previous_tab() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + 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.animation_level = "basic" + + async with app.run_test() as pilot: + underline = app.query_one(Underline) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + app.query_one(Tabs).action_previous_tab() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + 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.animation_level = "none" + + async with app.run_test() as pilot: + underline = app.query_one(Underline) + animator = app.animator + # Freeze time at 0 before triggering the animation. + animator._get_time = lambda *_: 0 + app.query_one(Tabs).action_previous_tab() + await pilot.pause() + # Freeze time after the animation start and before animation end. + animator._get_time = lambda *_: 0.01 + # Move to the next frame. + animator() + assert not animator.is_being_animated(underline, "highlight_start") + assert not animator.is_being_animated(underline, "highlight_end") diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py index ddb9dae16a..7ffb624d77 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -141,6 +141,63 @@ 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") + + # 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)) == 5 + + +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_no_children(): app = ExampleApp() async with app.run_test(): @@ -154,3 +211,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)