Merge branch 'main' into tree-deeply

This commit is contained in:
Dave Pearson
2023-01-27 09:23:49 +00:00
committed by GitHub
19 changed files with 365 additions and 110 deletions

View File

@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `TreeNode.expand_all` https://github.com/Textualize/textual/issues/1430 - Added `TreeNode.expand_all` https://github.com/Textualize/textual/issues/1430
- Added `TreeNode.collapse_all` https://github.com/Textualize/textual/issues/1430 - Added `TreeNode.collapse_all` https://github.com/Textualize/textual/issues/1430
- Added `TreeNode.toggle_all` https://github.com/Textualize/textual/issues/1430 - Added `TreeNode.toggle_all` https://github.com/Textualize/textual/issues/1430
- Added the coroutines `Animator.wait_until_complete` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658
- Added the method `Animator.is_being_animated` that checks if an attribute of an object is being animated or is scheduled for animation
### Changed ### Changed
@@ -21,8 +23,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed stuck screen https://github.com/Textualize/textual/issues/1632 - Fixed stuck screen https://github.com/Textualize/textual/issues/1632
- Fixed relative units in `grid-rows` and `grid-columns` being computed with respect to the wrong dimension https://github.com/Textualize/textual/issues/1406 - Fixed relative units in `grid-rows` and `grid-columns` being computed with respect to the wrong dimension https://github.com/Textualize/textual/issues/1406
- Fixed bug with animations that were triggered back to back, where the second one wouldn't start https://github.com/Textualize/textual/issues/1372
- Fixed bug with animations that were scheduled where all but the first would be skipped https://github.com/Textualize/textual/issues/1372
- Programmatically setting `overflow_x`/`overflow_y` refreshes the layout correctly https://github.com/Textualize/textual/issues/1616 - Programmatically setting `overflow_x`/`overflow_y` refreshes the layout correctly https://github.com/Textualize/textual/issues/1616
- Fixed double-paste into `Input` https://github.com/Textualize/textual/issues/1657 - Fixed double-paste into `Input` https://github.com/Textualize/textual/issues/1657
- Added a workaround for an apparent Windows Terminal paste issue https://github.com/Textualize/textual/issues/1661
## [0.10.1] - 2023-01-20 ## [0.10.1] - 2023-01-20

View File

@@ -1 +1 @@
::: textual.widgets.TreeNode ::: textual.widgets.tree.TreeNode

View File

@@ -1,17 +1,24 @@
# Introduction # Introduction
Welcome to the [Textual](https://github.com/Textualize/textual) framework documentation. Built with ❤️ by [Textualize.io](https://www.textualize.io) Welcome to the [Textual](https://github.com/Textualize/textual) framework documentation.
!!! tip
## In a hurry?
See the navigation links in the header or side-bars. Click the :octicons-three-bars-16: button (top left) on mobile. See the navigation links in the header or side-bars. Click the :octicons-three-bars-16: button (top left) on mobile.
[Get started](./getting_started.md){ .md-button .md-button--primary } or [Tutorial](./tutorial.md){ .md-button .md-button--secondary }
[Get started](./getting_started.md){ .md-button .md-button--primary } or go straight to the [Tutorial](./tutorial.md)
## What is Textual? ## What is Textual?
Textual is a framework for building applications that run within your terminal. Text User Interfaces (TUIs) have a number of advantages over web and desktop apps. Textual is a *Rapid Application Development* framework for Python, built by [Textualize.io](https://www.textualize.io).
Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and (*coming soon*) a web browser.
<div class="grid cards" markdown> <div class="grid cards" markdown>
@@ -26,7 +33,7 @@ Textual is a framework for building applications that run within your terminal.
--- ---
Low system requirements. Run Textual on a single board computer if you want to. Run Textual on a single board computer if you want to.
@@ -53,7 +60,7 @@ Textual is a framework for building applications that run within your terminal.
- :material-scale-balance:{ .lg .middle } __Open Source, MIT__ - :material-scale-balance:{ .lg .middle } __Open Source__
--- ---

View File

@@ -21,7 +21,7 @@ The example below creates a simple tree.
--8<-- "docs/examples/widgets/tree.py" --8<-- "docs/examples/widgets/tree.py"
``` ```
A each tree widget has a "root" attribute which is an instance of a [TreeNode][textual.widgets.TreeNode]. Call [add()][textual.widgets.TreeNode.add] or [add_leaf()][textual.widgets.TreeNode.add_leaf] to add new nodes underneath the root. Both these methods return a TreeNode for the child, so you can add more levels. Tree widgets have a "root" attribute which is an instance of a [TreeNode][textual.widgets.tree.TreeNode]. Call [add()][textual.widgets.tree.TreeNode.add] or [add_leaf()][textual.widgets.tree,TreeNode.add_leaf] to add new nodes underneath the root. Both these methods return a TreeNode for the child which you can use to add additional levels.
## Reactive Attributes ## Reactive Attributes
@@ -44,8 +44,8 @@ The `Tree.NodeSelected` message is sent when the user selects a tree node.
#### Attributes #### Attributes
| attribute | type | purpose | | attribute | type | purpose |
| --------- | ------------------------------------ | -------------- | | --------- | ----------------------------------------- | -------------- |
| `node` | [TreeNode][textual.widgets.TreeNode] | Selected node. | | `node` | [TreeNode][textual.widgets.tree.TreeNode] | Selected node. |
### NodeExpanded ### NodeExpanded
@@ -55,8 +55,8 @@ The `Tree.NodeExpanded` message is sent when the user expands a node in the tree
#### Attributes #### Attributes
| attribute | type | purpose | | attribute | type | purpose |
| --------- | ------------------------------------ | -------------- | | --------- | ----------------------------------------- | -------------- |
| `node` | [TreeNode][textual.widgets.TreeNode] | Expanded node. | | `node` | [TreeNode][textual.widgets.tree.TreeNode] | Expanded node. |
### NodeCollapsed ### NodeCollapsed
@@ -68,8 +68,8 @@ The `Tree.NodeCollapsed` message is sent when the user expands a node in the tre
#### Attributes #### Attributes
| attribute | type | purpose | | attribute | type | purpose |
| --------- | ------------------------------------ | --------------- | | --------- | ----------------------------------------- | --------------- |
| `node` | [TreeNode][textual.widgets.TreeNode] | Collapsed node. | | `node` | [TreeNode][textual.widgets.tree.TreeNode] | Collapsed node. |
@@ -77,4 +77,4 @@ The `Tree.NodeCollapsed` message is sent when the user expands a node in the tre
## See Also ## See Also
* [Tree][textual.widgets.Tree] code reference * [Tree][textual.widgets.Tree] code reference
* [TreeNode][textual.widgets.TreeNode] code reference * [TreeNode][textual.widgets.tree.TreeNode] code reference

View File

@@ -221,6 +221,7 @@ theme:
- navigation.tabs - navigation.tabs
- navigation.indexes - navigation.indexes
- navigation.tabs.sticky - navigation.tabs.sticky
- navigation.footer
- content.code.annotate - content.code.annotate
- content.code.copy - content.code.copy
palette: palette:

124
poetry.lock generated
View File

@@ -171,7 +171,7 @@ python-versions = "*"
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.0.5" version = "7.1.0"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
category = "dev" category = "dev"
optional = false optional = false
@@ -322,7 +322,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]] [[package]]
name = "identify" name = "identify"
version = "2.5.13" version = "2.5.15"
description = "File identification library for Python" description = "File identification library for Python"
category = "dev" category = "dev"
optional = false optional = false
@@ -521,7 +521,7 @@ doc = ["mkdocs-bootswatch (>=1,<2)", "mkdocs-minify-plugin (>=0.5.0,<0.6.0)", "p
[[package]] [[package]]
name = "mkdocstrings" name = "mkdocstrings"
version = "0.19.1" version = "0.20.0"
description = "Automatic documentation from sources, for MkDocs." description = "Automatic documentation from sources, for MkDocs."
category = "dev" category = "dev"
optional = false optional = false
@@ -626,7 +626,7 @@ python-versions = ">=3.7"
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "0.10.3" version = "0.11.0"
description = "Utility library for gitignore style pattern matching of file paths." description = "Utility library for gitignore style pattern matching of file paths."
category = "dev" category = "dev"
optional = false optional = false
@@ -1028,7 +1028,7 @@ dev = ["aiohttp", "click", "msgpack"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.7" python-versions = "^3.7"
content-hash = "0e3bcf48b37c16096a3c2b2f7d3f548494f9a22ebdee2e2c5d8ac74b80ab344e" content-hash = "d76445ef1521cd4068907433b09d59fc1ed56f03e61063c5ad7376bb9823a8e7"
[metadata.files] [metadata.files]
aiohttp = [ aiohttp = [
@@ -1193,57 +1193,57 @@ colored = [
{file = "colored-1.4.4.tar.gz", hash = "sha256:04ff4d4dd514274fe3b99a21bb52fb96f2688c01e93fba7bef37221e7cb56ce0"}, {file = "colored-1.4.4.tar.gz", hash = "sha256:04ff4d4dd514274fe3b99a21bb52fb96f2688c01e93fba7bef37221e7cb56ce0"},
] ]
coverage = [ coverage = [
{file = "coverage-7.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2a7f23bbaeb2a87f90f607730b45564076d870f1fb07b9318d0c21f36871932b"}, {file = "coverage-7.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b946bbcd5a8231383450b195cfb58cb01cbe7f8949f5758566b881df4b33baf"},
{file = "coverage-7.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c18d47f314b950dbf24a41787ced1474e01ca816011925976d90a88b27c22b89"}, {file = "coverage-7.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec8e767f13be637d056f7e07e61d089e555f719b387a7070154ad80a0ff31801"},
{file = "coverage-7.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef14d75d86f104f03dea66c13188487151760ef25dd6b2dbd541885185f05f40"}, {file = "coverage-7.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a5a5879a939cb84959d86869132b00176197ca561c664fc21478c1eee60d75"},
{file = "coverage-7.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66e50680e888840c0995f2ad766e726ce71ca682e3c5f4eee82272c7671d38a2"}, {file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b643cb30821e7570c0aaf54feaf0bfb630b79059f85741843e9dc23f33aaca2c"},
{file = "coverage-7.0.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9fed35ca8c6e946e877893bbac022e8563b94404a605af1d1e6accc7eb73289"}, {file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32df215215f3af2c1617a55dbdfb403b772d463d54d219985ac7cd3bf124cada"},
{file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d8d04e755934195bdc1db45ba9e040b8d20d046d04d6d77e71b3b34a8cc002d0"}, {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:33d1ae9d4079e05ac4cc1ef9e20c648f5afabf1a92adfaf2ccf509c50b85717f"},
{file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e109f1c9a3ece676597831874126555997c48f62bddbcace6ed17be3e372de8"}, {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:29571503c37f2ef2138a306d23e7270687c0efb9cab4bd8038d609b5c2393a3a"},
{file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0a1890fca2962c4f1ad16551d660b46ea77291fba2cc21c024cd527b9d9c8809"}, {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:63ffd21aa133ff48c4dff7adcc46b7ec8b565491bfc371212122dd999812ea1c"},
{file = "coverage-7.0.5-cp310-cp310-win32.whl", hash = "sha256:be9fcf32c010da0ba40bf4ee01889d6c737658f4ddff160bd7eb9cac8f094b21"}, {file = "coverage-7.1.0-cp310-cp310-win32.whl", hash = "sha256:4b14d5e09c656de5038a3f9bfe5228f53439282abcab87317c9f7f1acb280352"},
{file = "coverage-7.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:cbfcba14a3225b055a28b3199c3d81cd0ab37d2353ffd7f6fd64844cebab31ad"}, {file = "coverage-7.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:8361be1c2c073919500b6601220a6f2f98ea0b6d2fec5014c1d9cfa23dd07038"},
{file = "coverage-7.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:30b5fec1d34cc932c1bc04017b538ce16bf84e239378b8f75220478645d11fca"}, {file = "coverage-7.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:da9b41d4539eefd408c46725fb76ecba3a50a3367cafb7dea5f250d0653c1040"},
{file = "coverage-7.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1caed2367b32cc80a2b7f58a9f46658218a19c6cfe5bc234021966dc3daa01f0"}, {file = "coverage-7.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5b15ed7644ae4bee0ecf74fee95808dcc34ba6ace87e8dfbf5cb0dc20eab45a"},
{file = "coverage-7.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d254666d29540a72d17cc0175746cfb03d5123db33e67d1020e42dae611dc196"}, {file = "coverage-7.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d12d076582507ea460ea2a89a8c85cb558f83406c8a41dd641d7be9a32e1274f"},
{file = "coverage-7.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19245c249aa711d954623d94f23cc94c0fd65865661f20b7781210cb97c471c0"}, {file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2617759031dae1bf183c16cef8fcfb3de7617f394c813fa5e8e46e9b82d4222"},
{file = "coverage-7.0.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b05ed4b35bf6ee790832f68932baf1f00caa32283d66cc4d455c9e9d115aafc"}, {file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4e4881fa9e9667afcc742f0c244d9364d197490fbc91d12ac3b5de0bf2df146"},
{file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:29de916ba1099ba2aab76aca101580006adfac5646de9b7c010a0f13867cba45"}, {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9d58885215094ab4a86a6aef044e42994a2bd76a446dc59b352622655ba6621b"},
{file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e057e74e53db78122a3979f908973e171909a58ac20df05c33998d52e6d35757"}, {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ffeeb38ee4a80a30a6877c5c4c359e5498eec095878f1581453202bfacc8fbc2"},
{file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:411d4ff9d041be08fdfc02adf62e89c735b9468f6d8f6427f8a14b6bb0a85095"}, {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3baf5f126f30781b5e93dbefcc8271cb2491647f8283f20ac54d12161dff080e"},
{file = "coverage-7.0.5-cp311-cp311-win32.whl", hash = "sha256:52ab14b9e09ce052237dfe12d6892dd39b0401690856bcfe75d5baba4bfe2831"}, {file = "coverage-7.1.0-cp311-cp311-win32.whl", hash = "sha256:ded59300d6330be27bc6cf0b74b89ada58069ced87c48eaf9344e5e84b0072f7"},
{file = "coverage-7.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:1f66862d3a41674ebd8d1a7b6f5387fe5ce353f8719040a986551a545d7d83ea"}, {file = "coverage-7.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a43c7823cd7427b4ed763aa7fb63901ca8288591323b58c9cd6ec31ad910f3c"},
{file = "coverage-7.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b69522b168a6b64edf0c33ba53eac491c0a8f5cc94fa4337f9c6f4c8f2f5296c"}, {file = "coverage-7.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a726d742816cb3a8973c8c9a97539c734b3a309345236cd533c4883dda05b8d"},
{file = "coverage-7.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436e103950d05b7d7f55e39beeb4d5be298ca3e119e0589c0227e6d0b01ee8c7"}, {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc7c85a150501286f8b56bd8ed3aa4093f4b88fb68c0843d21ff9656f0009d6a"},
{file = "coverage-7.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c56bec53d6e3154eaff6ea941226e7bd7cc0d99f9b3756c2520fc7a94e6d96"}, {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b4198d85a3755d27e64c52f8c95d6333119e49fd001ae5798dac872c95e0f8"},
{file = "coverage-7.0.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a38362528a9115a4e276e65eeabf67dcfaf57698e17ae388599568a78dcb029"}, {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddb726cb861c3117a553f940372a495fe1078249ff5f8a5478c0576c7be12050"},
{file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f67472c09a0c7486e27f3275f617c964d25e35727af952869dd496b9b5b7f6a3"}, {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:51b236e764840a6df0661b67e50697aaa0e7d4124ca95e5058fa3d7cbc240b7c"},
{file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:220e3fa77d14c8a507b2d951e463b57a1f7810a6443a26f9b7591ef39047b1b2"}, {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7ee5c9bb51695f80878faaa5598040dd6c9e172ddcf490382e8aedb8ec3fec8d"},
{file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ecb0f73954892f98611e183f50acdc9e21a4653f294dfbe079da73c6378a6f47"}, {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c31b75ae466c053a98bf26843563b3b3517b8f37da4d47b1c582fdc703112bc3"},
{file = "coverage-7.0.5-cp37-cp37m-win32.whl", hash = "sha256:d8f3e2e0a1d6777e58e834fd5a04657f66affa615dae61dd67c35d1568c38882"}, {file = "coverage-7.1.0-cp37-cp37m-win32.whl", hash = "sha256:3b155caf3760408d1cb903b21e6a97ad4e2bdad43cbc265e3ce0afb8e0057e73"},
{file = "coverage-7.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9e662e6fc4f513b79da5d10a23edd2b87685815b337b1a30cd11307a6679148d"}, {file = "coverage-7.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2a60d6513781e87047c3e630b33b4d1e89f39836dac6e069ffee28c4786715f5"},
{file = "coverage-7.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:790e4433962c9f454e213b21b0fd4b42310ade9c077e8edcb5113db0818450cb"}, {file = "coverage-7.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f2cba5c6db29ce991029b5e4ac51eb36774458f0a3b8d3137241b32d1bb91f06"},
{file = "coverage-7.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49640bda9bda35b057b0e65b7c43ba706fa2335c9a9896652aebe0fa399e80e6"}, {file = "coverage-7.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beeb129cacea34490ffd4d6153af70509aa3cda20fdda2ea1a2be870dfec8d52"},
{file = "coverage-7.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d66187792bfe56f8c18ba986a0e4ae44856b1c645336bd2c776e3386da91e1dd"}, {file = "coverage-7.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c45948f613d5d18c9ec5eaa203ce06a653334cf1bd47c783a12d0dd4fd9c851"},
{file = "coverage-7.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:276f4cd0001cd83b00817c8db76730938b1ee40f4993b6a905f40a7278103b3a"}, {file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef382417db92ba23dfb5864a3fc9be27ea4894e86620d342a116b243ade5d35d"},
{file = "coverage-7.0.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95304068686545aa368b35dfda1cdfbbdbe2f6fe43de4a2e9baa8ebd71be46e2"}, {file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c7c0d0827e853315c9bbd43c1162c006dd808dbbe297db7ae66cd17b07830f0"},
{file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:17e01dd8666c445025c29684d4aabf5a90dc6ef1ab25328aa52bedaa95b65ad7"}, {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e5cdbb5cafcedea04924568d990e20ce7f1945a1dd54b560f879ee2d57226912"},
{file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea76dbcad0b7b0deb265d8c36e0801abcddf6cc1395940a24e3595288b405ca0"}, {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9817733f0d3ea91bea80de0f79ef971ae94f81ca52f9b66500c6a2fea8e4b4f8"},
{file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:50a6adc2be8edd7ee67d1abc3cd20678987c7b9d79cd265de55941e3d0d56499"}, {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:218fe982371ac7387304153ecd51205f14e9d731b34fb0568181abaf7b443ba0"},
{file = "coverage-7.0.5-cp38-cp38-win32.whl", hash = "sha256:e4ce984133b888cc3a46867c8b4372c7dee9cee300335e2925e197bcd45b9e16"}, {file = "coverage-7.1.0-cp38-cp38-win32.whl", hash = "sha256:04481245ef966fbd24ae9b9e537ce899ae584d521dfbe78f89cad003c38ca2ab"},
{file = "coverage-7.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:4a950f83fd3f9bca23b77442f3a2b2ea4ac900944d8af9993743774c4fdc57af"}, {file = "coverage-7.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8ae125d1134bf236acba8b83e74c603d1b30e207266121e76484562bc816344c"},
{file = "coverage-7.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c2155943896ac78b9b0fd910fb381186d0c345911f5333ee46ac44c8f0e43ab"}, {file = "coverage-7.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2bf1d5f2084c3932b56b962a683074a3692bce7cabd3aa023c987a2a8e7612f6"},
{file = "coverage-7.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54f7e9705e14b2c9f6abdeb127c390f679f6dbe64ba732788d3015f7f76ef637"}, {file = "coverage-7.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:98b85dd86514d889a2e3dd22ab3c18c9d0019e696478391d86708b805f4ea0fa"},
{file = "coverage-7.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ee30375b409d9a7ea0f30c50645d436b6f5dfee254edffd27e45a980ad2c7f4"}, {file = "coverage-7.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38da2db80cc505a611938d8624801158e409928b136c8916cd2e203970dde4dc"},
{file = "coverage-7.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b78729038abea6a5df0d2708dce21e82073463b2d79d10884d7d591e0f385ded"}, {file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3164d31078fa9efe406e198aecd2a02d32a62fecbdef74f76dad6a46c7e48311"},
{file = "coverage-7.0.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13250b1f0bd023e0c9f11838bdeb60214dd5b6aaf8e8d2f110c7e232a1bff83b"}, {file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db61a79c07331e88b9a9974815c075fbd812bc9dbc4dc44b366b5368a2936063"},
{file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2c407b1950b2d2ffa091f4e225ca19a66a9bd81222f27c56bd12658fc5ca1209"}, {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ccb092c9ede70b2517a57382a601619d20981f56f440eae7e4d7eaafd1d1d09"},
{file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c76a3075e96b9c9ff00df8b5f7f560f5634dffd1658bafb79eb2682867e94f78"}, {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:33ff26d0f6cc3ca8de13d14fde1ff8efe1456b53e3f0273e63cc8b3c84a063d8"},
{file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f26648e1b3b03b6022b48a9b910d0ae209e2d51f50441db5dce5b530fad6d9b1"}, {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d47dd659a4ee952e90dc56c97d78132573dc5c7b09d61b416a9deef4ebe01a0c"},
{file = "coverage-7.0.5-cp39-cp39-win32.whl", hash = "sha256:ba3027deb7abf02859aca49c865ece538aee56dcb4871b4cced23ba4d5088904"}, {file = "coverage-7.1.0-cp39-cp39-win32.whl", hash = "sha256:d248cd4a92065a4d4543b8331660121b31c4148dd00a691bfb7a5cdc7483cfa4"},
{file = "coverage-7.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:949844af60ee96a376aac1ded2a27e134b8c8d35cc006a52903fc06c24a3296f"}, {file = "coverage-7.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7ed681b0f8e8bcbbffa58ba26fcf5dbc8f79e7997595bf071ed5430d8c08d6f3"},
{file = "coverage-7.0.5-pp37.pp38.pp39-none-any.whl", hash = "sha256:b9727ac4f5cf2cbf87880a63870b5b9730a8ae3a4a360241a0fdaa2f71240ff0"}, {file = "coverage-7.1.0-pp37.pp38.pp39-none-any.whl", hash = "sha256:755e89e32376c850f826c425ece2c35a4fc266c081490eb0a841e7c1cb0d3bda"},
{file = "coverage-7.0.5.tar.gz", hash = "sha256:051afcbd6d2ac39298d62d340f94dbb6a1f31de06dfaf6fcef7b759dd3860c45"}, {file = "coverage-7.1.0.tar.gz", hash = "sha256:10188fe543560ec4874f974b5305cd1a8bdcfa885ee00ea3a03733464c4ca265"},
] ]
distlib = [ distlib = [
{file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"},
@@ -1362,8 +1362,8 @@ httpx = [
{file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"},
] ]
identify = [ identify = [
{file = "identify-2.5.13-py2.py3-none-any.whl", hash = "sha256:8aa48ce56e38c28b6faa9f261075dea0a942dfbb42b341b4e711896cbb40f3f7"}, {file = "identify-2.5.15-py2.py3-none-any.whl", hash = "sha256:1f4b36c5f50f3f950864b2a047308743f064eaa6f6645da5e5c780d1c7125487"},
{file = "identify-2.5.13.tar.gz", hash = "sha256:abb546bca6f470228785338a01b539de8a85bbf46491250ae03363956d8ebb10"}, {file = "identify-2.5.15.tar.gz", hash = "sha256:c22aa206f47cc40486ecf585d27ad5f40adbfc494a3fa41dc3ed0499a23b123f"},
] ]
idna = [ idna = [
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
@@ -1470,8 +1470,8 @@ mkdocs-rss-plugin = [
{file = "mkdocs_rss_plugin-1.5.0-py2.py3-none-any.whl", hash = "sha256:2ab14c20bf6b7983acbe50181e7e4a0778731d9c2d5c38107ca7047a7abd2165"}, {file = "mkdocs_rss_plugin-1.5.0-py2.py3-none-any.whl", hash = "sha256:2ab14c20bf6b7983acbe50181e7e4a0778731d9c2d5c38107ca7047a7abd2165"},
] ]
mkdocstrings = [ mkdocstrings = [
{file = "mkdocstrings-0.19.1-py3-none-any.whl", hash = "sha256:32a38d88f67f65b264184ea71290f9332db750d189dea4200cbbe408d304c261"}, {file = "mkdocstrings-0.20.0-py3-none-any.whl", hash = "sha256:f17fc2c4f760ec302b069075ef9e31045aa6372ca91d2f35ded3adba8e25a472"},
{file = "mkdocstrings-0.19.1.tar.gz", hash = "sha256:d1037cacb4b522c1e8c164ed5d00d724a82e49dcee0af80db8fb67b384faeef9"}, {file = "mkdocstrings-0.20.0.tar.gz", hash = "sha256:c757f4f646d4f939491d6bc9256bfe33e36c5f8026392f49eaa351d241c838e5"},
] ]
mkdocstrings-python = [ mkdocstrings-python = [
{file = "mkdocstrings-python-0.8.3.tar.gz", hash = "sha256:9ae473f6dc599339b09eee17e4d2b05d6ac0ec29860f3fc9b7512d940fc61adf"}, {file = "mkdocstrings-python-0.8.3.tar.gz", hash = "sha256:9ae473f6dc599339b09eee17e4d2b05d6ac0ec29860f3fc9b7512d940fc61adf"},
@@ -1656,8 +1656,8 @@ packaging = [
{file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
] ]
pathspec = [ pathspec = [
{file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"}, {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"},
{file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"}, {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"},
] ]
platformdirs = [ platformdirs = [
{file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"}, {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"},

View File

@@ -50,7 +50,7 @@ black = "^22.3.0,<22.10.0" # macos wheel issue on 22.10
mypy = "^0.990" mypy = "^0.990"
pytest-cov = "^2.12.1" pytest-cov = "^2.12.1"
mkdocs = "^1.3.0" mkdocs = "^1.3.0"
mkdocstrings = {extras = ["python"], version = "^0.19.0"} mkdocstrings = {extras = ["python"], version = "^0.20.0"}
mkdocs-material = "^8.2.15" mkdocs-material = "^8.2.15"
pre-commit = "^2.13.0" pre-commit = "^2.13.0"
pytest-aiohttp = "^1.0.4" pytest-aiohttp = "^1.0.4"

View File

@@ -21,6 +21,9 @@ else: # pragma: no cover
if TYPE_CHECKING: if TYPE_CHECKING:
from textual.app import App from textual.app import App
AnimationKey = tuple[int, str]
"""Animation keys are the id of the object and the attribute being animated."""
EasingFunction = Callable[[float], float] EasingFunction = Callable[[float], float]
@@ -166,10 +169,19 @@ class BoundAnimator:
class Animator: class Animator:
"""An object to manage updates to a given attribute over a period of time.""" """An object to manage updates to a given attribute over a period of time.
Attrs:
_animations: Dictionary that maps animation keys to the corresponding animation
instances.
_scheduled: Keys corresponding to animations that have been scheduled but not yet
started.
app: The app that owns the animator object.
"""
def __init__(self, app: App, frames_per_second: int = 60) -> None: def __init__(self, app: App, frames_per_second: int = 60) -> None:
self._animations: dict[tuple[object, str], Animation] = {} self._animations: dict[AnimationKey, Animation] = {}
self._scheduled: set[AnimationKey] = set()
self.app = app self.app = app
self._timer = Timer( self._timer = Timer(
app, app,
@@ -179,11 +191,15 @@ class Animator:
callback=self, callback=self,
pause=True, pause=True,
) )
# Flag if no animations are currently taking place.
self._idle_event = asyncio.Event() self._idle_event = asyncio.Event()
# Flag if no animations are currently taking place and none are scheduled.
self._complete_event = asyncio.Event()
async def start(self) -> None: async def start(self) -> None:
"""Start the animator task.""" """Start the animator task."""
self._idle_event.set() self._idle_event.set()
self._complete_event.set()
self._timer.start() self._timer.start()
async def stop(self) -> None: async def stop(self) -> None:
@@ -194,11 +210,17 @@ class Animator:
pass pass
finally: finally:
self._idle_event.set() self._idle_event.set()
self._complete_event.set()
def bind(self, obj: object) -> BoundAnimator: def bind(self, obj: object) -> BoundAnimator:
"""Bind the animator to a given objects.""" """Bind the animator to a given object."""
return BoundAnimator(self, obj) return BoundAnimator(self, obj)
def is_being_animated(self, obj: object, attribute: str) -> bool:
"""Does the object/attribute pair have an ongoing or scheduled animation?"""
key = (id(obj), attribute)
return key in self._animations or key in self._scheduled
def animate( def animate(
self, self,
obj: object, obj: object,
@@ -237,6 +259,8 @@ class Animator:
on_complete=on_complete, on_complete=on_complete,
) )
if delay: if delay:
self._scheduled.add((id(obj), attribute))
self._complete_event.clear()
self.app.set_timer(delay, animate_callback) self.app.set_timer(delay, animate_callback)
else: else:
animate_callback() animate_callback()
@@ -273,13 +297,14 @@ class Animator:
duration is None and speed is not None duration is None and speed is not None
), "An Animation should have a duration OR a speed" ), "An Animation should have a duration OR a speed"
animation_key = (id(obj), attribute)
self._scheduled.discard(animation_key)
if final_value is ...: if final_value is ...:
final_value = value final_value = value
start_time = self._get_time() start_time = self._get_time()
animation_key = (id(obj), attribute)
easing_function = EASING[easing] if isinstance(easing, str) else easing easing_function = EASING[easing] if isinstance(easing, str) else easing
animation: Animation | None = None animation: Animation | None = None
@@ -342,11 +367,14 @@ class Animator:
self._animations[animation_key] = animation self._animations[animation_key] = animation
self._timer.resume() self._timer.resume()
self._idle_event.clear() self._idle_event.clear()
self._complete_event.clear()
async def __call__(self) -> None: async def __call__(self) -> None:
if not self._animations: if not self._animations:
self._timer.pause() self._timer.pause()
self._idle_event.set() self._idle_event.set()
if not self._scheduled:
self._complete_event.set()
else: else:
animation_time = self._get_time() animation_time = self._get_time()
animation_keys = list(self._animations.keys()) animation_keys = list(self._animations.keys())
@@ -368,3 +396,7 @@ class Animator:
async def wait_for_idle(self) -> None: async def wait_for_idle(self) -> None:
"""Wait for any animations to complete.""" """Wait for any animations to complete."""
await self._idle_event.wait() await self._idle_event.wait()
async def wait_until_complete(self) -> None:
"""Wait for any current and scheduled animations to complete."""
await self._complete_event.wait()

View File

@@ -4,6 +4,7 @@ from contextvars import ContextVar
if TYPE_CHECKING: if TYPE_CHECKING:
from .app import App from .app import App
from .message_pump import MessagePump
class NoActiveAppError(RuntimeError): class NoActiveAppError(RuntimeError):
@@ -11,3 +12,4 @@ class NoActiveAppError(RuntimeError):
active_app: ContextVar["App"] = ContextVar("active_app") active_app: ContextVar["App"] = ContextVar("active_app")
active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump")

View File

@@ -118,7 +118,10 @@ class XTermParser(Parser[events.Event]):
# ESC from the closing bracket, since at that point we didn't know what # ESC from the closing bracket, since at that point we didn't know what
# the full escape code was. # the full escape code was.
pasted_text = "".join(paste_buffer[:-1]) pasted_text = "".join(paste_buffer[:-1])
on_token(events.Paste(self.sender, text=pasted_text)) # Note the removal of NUL characters: https://github.com/Textualize/textual/issues/1661
on_token(
events.Paste(self.sender, text=pasted_text.replace("\x00", ""))
)
paste_buffer.clear() paste_buffer.clear()
character = ESC if use_prior_escape else (yield read1()) character = ESC if use_prior_escape else (yield read1())

View File

@@ -46,7 +46,7 @@ from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction
from ._ansi_sequences import SYNC_END, SYNC_START from ._ansi_sequences import SYNC_END, SYNC_START
from ._asyncio import create_task from ._asyncio import create_task
from ._callback import invoke from ._callback import invoke
from ._context import active_app from ._context import active_app, active_message_pump
from ._event_broker import NoHandler, extract_handler_actions from ._event_broker import NoHandler, extract_handler_actions
from ._filter import LineFilter, Monochrome from ._filter import LineFilter, Monochrome
from ._path import _make_path_object_relative from ._path import _make_path_object_relative
@@ -1117,6 +1117,7 @@ class App(Generic[ReturnType], DOMNode):
def mount_all( def mount_all(
self, self,
widgets: Iterable[Widget], widgets: Iterable[Widget],
*,
before: int | str | Widget | None = None, before: int | str | Widget | None = None,
after: int | str | Widget | None = None, after: int | str | Widget | None = None,
) -> AwaitMount: ) -> AwaitMount:
@@ -2111,7 +2112,7 @@ class App(Generic[ReturnType], DOMNode):
"""Remove nodes from DOM, and return an awaitable that awaits cleanup. """Remove nodes from DOM, and return an awaitable that awaits cleanup.
Args: Args:
widgets: List of nodes to remvoe. widgets: List of nodes to remove.
Returns: Returns:
Awaitable that returns when the nodes have been fully removed. Awaitable that returns when the nodes have been fully removed.
@@ -2135,17 +2136,19 @@ class App(Generic[ReturnType], DOMNode):
removed_widgets = self._detach_from_dom(widgets) removed_widgets = self._detach_from_dom(widgets)
finished_event = asyncio.Event() finished_event = asyncio.Event()
create_task( remove_task = create_task(
prune_widgets_task(removed_widgets, finished_event), name="prune nodes" prune_widgets_task(removed_widgets, finished_event), name="prune nodes"
) )
return AwaitRemove(finished_event) await_remove = AwaitRemove(finished_event, remove_task)
self.call_next(await_remove)
return await_remove
async def _prune_nodes(self, widgets: list[Widget]) -> None: async def _prune_nodes(self, widgets: list[Widget]) -> None:
"""Remove nodes and children. """Remove nodes and children.
Args: Args:
widgets: _description_ widgets: Widgets to remove.
""" """
async with self._dom_lock: async with self._dom_lock:
for widget in widgets: for widget in widgets:

View File

@@ -1,19 +1,24 @@
"""Provides the type of an awaitable remove.""" """Provides the type of an awaitable remove."""
from asyncio import Event from asyncio import Event, Task
from typing import Generator from typing import Generator
class AwaitRemove: class AwaitRemove:
"""An awaitable returned by App.remove and DOMQuery.remove.""" """An awaitable returned by App.remove and DOMQuery.remove."""
def __init__(self, finished_flag: Event) -> None: def __init__(self, finished_flag: Event, task: Task) -> None:
"""Initialise the instance of ``AwaitRemove``. """Initialise the instance of ``AwaitRemove``.
Args: Args:
finished_flag: The asyncio event to wait on. finished_flag: The asyncio event to wait on.
task: The task which does the remove (required to keep a reference).
""" """
self.finished_flag = finished_flag self.finished_flag = finished_flag
self._task = task
async def __call__(self) -> None:
return await self
def __await__(self) -> Generator[None, None, None]: def __await__(self) -> Generator[None, None, None]:
async def await_prune() -> None: async def await_prune() -> None:

View File

@@ -449,6 +449,8 @@ class Stylesheet:
get_new_render_rule = new_render_rules.get get_new_render_rule = new_render_rules.get
if animate: if animate:
animator = node.app.animator
base = node.styles.base
for key in modified_rule_keys: for key in modified_rule_keys:
# Get old and new render rules # Get old and new render rules
old_render_value = get_current_render_rule(key) old_render_value = get_current_render_rule(key)
@@ -456,13 +458,18 @@ class Stylesheet:
# Get new rule value (may be None) # Get new rule value (may be None)
new_value = rules.get(key) new_value = rules.get(key)
# Check if this can / should be animated # Check if this can / should be animated. It doesn't suffice to check
if is_animatable(key) and new_render_value != old_render_value: # if the current and target values are different because a previous
# animation may have been scheduled but may have not started yet.
if is_animatable(key) and (
new_render_value != old_render_value
or animator.is_being_animated(base, key)
):
transition = new_styles._get_transition(key) transition = new_styles._get_transition(key)
if transition is not None: if transition is not None:
duration, easing, delay = transition duration, easing, delay = transition
node.app.animator.animate( animator.animate(
node.styles.base, base,
key, key,
new_render_value, new_render_value,
final_value=new_value, final_value=new_value,

View File

@@ -145,7 +145,12 @@ class LinuxDriver(Driver):
self._request_terminal_sync_mode_support() self._request_terminal_sync_mode_support()
self._enable_bracketed_paste() self._enable_bracketed_paste()
def _request_terminal_sync_mode_support(self): def _request_terminal_sync_mode_support(self) -> None:
"""Writes an escape sequence to query the terminal support for the sync protocol."""
# Terminals should ignore this sequence if not supported.
# Apple terminal doesn't, and writes a single 'p' in to the terminal,
# so we will make a special case for Apple terminal (which doesn't support sync anyway).
if self.console._environ.get("TERM_PROGRAM", "") != "Apple_Terminal":
self.console.file.write("\033[?2026$p") self.console.file.write("\033[?2026$p")
self.console.file.flush() self.console.file.flush()

View File

@@ -17,7 +17,7 @@ from weakref import WeakSet
from . import Logger, events, log, messages from . import Logger, events, log, messages
from ._asyncio import create_task from ._asyncio import create_task
from ._callback import invoke from ._callback import invoke
from ._context import NoActiveAppError, active_app from ._context import NoActiveAppError, active_app, active_message_pump
from ._time import time from ._time import time
from .case import camel_to_snake from .case import camel_to_snake
from .errors import DuplicateKeyHandlers from .errors import DuplicateKeyHandlers
@@ -313,12 +313,18 @@ class MessagePump(metaclass=MessagePumpMeta):
Reactive._reset_object(self) Reactive._reset_object(self)
await self._message_queue.put(None) await self._message_queue.put(None)
if wait and self._task is not None and asyncio.current_task() != self._task: if wait and self._task is not None and asyncio.current_task() != self._task:
# Ensure everything is closed before returning try:
running_widget = active_message_pump.get()
except LookupError:
running_widget = None
if running_widget is None or running_widget is not self:
await self._task await self._task
def _start_messages(self) -> None: def _start_messages(self) -> None:
"""Start messages task.""" """Start messages task."""
if self.app._running: if self.app._running:
active_message_pump.set(self)
self._task = create_task( self._task = create_task(
self._process_messages(), name=f"message pump {self}" self._process_messages(), name=f"message pump {self}"
) )
@@ -357,7 +363,6 @@ class MessagePump(metaclass=MessagePumpMeta):
async def _process_messages_loop(self) -> None: async def _process_messages_loop(self) -> None:
"""Process messages until the queue is closed.""" """Process messages until the queue is closed."""
_rich_traceback_guard = True _rich_traceback_guard = True
while not self._closed: while not self._closed:
try: try:
message = await self._get_message() message = await self._get_message()

View File

@@ -43,9 +43,13 @@ class Pilot(Generic[ReturnType]):
await asyncio.sleep(delay) await asyncio.sleep(delay)
async def wait_for_animation(self) -> None: async def wait_for_animation(self) -> None:
"""Wait for any animation to complete.""" """Wait for any current animation to complete."""
await self._app.animator.wait_for_idle() await self._app.animator.wait_for_idle()
async def wait_for_scheduled_animations(self) -> None:
"""Wait for any current and scheduled animations to complete."""
await self._app.animator.wait_until_complete()
async def exit(self, result: ReturnType) -> None: async def exit(self, result: ReturnType) -> None:
"""Exit the app with the given result. """Exit the app with the given result.

View File

@@ -617,6 +617,37 @@ class Widget(DOMNode):
self.call_next(await_mount) self.call_next(await_mount)
return await_mount return await_mount
def mount_all(
self,
widgets: Iterable[Widget],
*,
before: int | str | Widget | None = None,
after: int | str | Widget | None = None,
) -> AwaitMount:
"""Mount widgets from an iterable.
Args:
widgets: An iterable of widgets.
before: Optional location to mount before. An `int` is the index
of the child to mount before, a `str` is a `query_one` query to
find the widget to mount before.
after: Optional location to mount after. An `int` is the index
of the child to mount after, a `str` is a `query_one` query to
find the widget to mount after.
Returns:
An awaitable object that waits for widgets to be mounted.
Raises:
MountError: If there is a problem with the mount request.
Note:
Only one of ``before`` or ``after`` can be provided. If both are
provided a ``MountError`` will be raised.
"""
await_mount = self.mount(*widgets, before=before, after=after)
return await_mount
def move_child( def move_child(
self, self,
child: int | Widget, child: int | Widget,

View File

@@ -37,3 +37,124 @@ async def test_animate_height() -> None:
assert elapsed >= 0.5 assert elapsed >= 0.5
# Check the height reached the maximum # Check the height reached the maximum
assert static.styles.height.value == 100 assert static.styles.height.value == 100
async def test_scheduling_animation() -> None:
"""Test that scheduling an animation works."""
app = AnimApp()
delay = 0.1
async with app.run_test() as pilot:
styles = app.query_one(Static).styles
styles.background = "black"
styles.animate("background", "white", delay=delay, duration=0)
await pilot.pause(0.9 * delay)
assert styles.background.rgb == (0, 0, 0) # Still black
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (255, 255, 255)
async def test_wait_for_current_animations() -> None:
"""Test that we can wait only for the current animations taking place."""
app = AnimApp()
delay = 10
async with app.run_test() as pilot:
styles = app.query_one(Static).styles
styles.animate("height", 100, duration=0.1)
start = perf_counter()
styles.animate("height", 200, duration=0.1, delay=delay)
# Wait for the first animation to finish
await pilot.wait_for_animation()
elapsed = perf_counter() - start
assert elapsed < (delay / 2)
async def test_wait_for_current_and_scheduled_animations() -> None:
"""Test that we can wait for current and scheduled animations."""
app = AnimApp()
async with app.run_test() as pilot:
styles = app.query_one(Static).styles
start = perf_counter()
styles.animate("height", 50, duration=0.01)
styles.animate("background", "black", duration=0.01, delay=0.05)
await pilot.wait_for_scheduled_animations()
elapsed = perf_counter() - start
assert elapsed >= 0.06
assert styles.background.rgb == (0, 0, 0)
async def test_reverse_animations() -> None:
"""Test that you can create reverse animations.
Regression test for #1372 https://github.com/Textualize/textual/issues/1372
"""
app = AnimApp()
async with app.run_test() as pilot:
static = app.query_one(Static)
styles = static.styles
# Starting point.
styles.background = "black"
assert styles.background.rgb == (0, 0, 0)
# First, make sure we can go from black to white and back, step by step.
styles.animate("background", "white", duration=0.01)
await pilot.wait_for_animation()
assert styles.background.rgb == (255, 255, 255)
styles.animate("background", "black", duration=0.01)
await pilot.wait_for_animation()
assert styles.background.rgb == (0, 0, 0)
# Now, the actual test is to make sure we go back to black if creating both at once.
styles.animate("background", "white", duration=0.01)
styles.animate("background", "black", duration=0.01)
await pilot.wait_for_animation()
assert styles.background.rgb == (0, 0, 0)
async def test_schedule_reverse_animations() -> None:
"""Test that you can schedule reverse animations.
Regression test for #1372 https://github.com/Textualize/textual/issues/1372
"""
app = AnimApp()
async with app.run_test() as pilot:
static = app.query_one(Static)
styles = static.styles
# Starting point.
styles.background = "black"
assert styles.background.rgb == (0, 0, 0)
# First, make sure we can go from black to white and back, step by step.
styles.animate("background", "white", delay=0.01, duration=0.01)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (255, 255, 255)
styles.animate("background", "black", delay=0.01, duration=0.01)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (0, 0, 0)
# Now, the actual test is to make sure we go back to black if scheduling both at once.
styles.animate("background", "white", delay=0.01, duration=0.01)
await pilot.pause(0.005)
styles.animate("background", "black", delay=0.01, duration=0.01)
await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (0, 0, 0)

View File

@@ -1,13 +1,12 @@
import pytest import pytest
import rich
from textual._node_list import DuplicateIds from textual._node_list import DuplicateIds
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.css.errors import StyleValueError from textual.css.errors import StyleValueError
from textual.css.query import NoMatches from textual.css.query import NoMatches
from textual.dom import DOMNode
from textual.geometry import Size from textual.geometry import Size
from textual.widget import Widget, MountError from textual.widget import Widget, MountError
from textual.widgets import Label
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -157,3 +156,28 @@ def test_widget_mount_ids_must_be_unique_mounting_multiple_calls(parent):
parent.mount(widget1) parent.mount(widget1)
with pytest.raises(DuplicateIds): with pytest.raises(DuplicateIds):
parent.mount(widget2) parent.mount(widget2)
# Regression test for https://github.com/Textualize/textual/issues/1634
async def test_remove():
class RemoveMeLabel(Label):
async def on_mount(self) -> None:
await self.action("app.remove_all")
class Container(Widget):
async def clear(self) -> None:
await self.query("*").remove()
class RemoveApp(App):
def compose(self) -> ComposeResult:
yield Container(RemoveMeLabel())
async def action_remove_all(self) -> None:
await self.query_one(Container).clear()
self.exit(123)
app = RemoveApp()
async with app.run_test() as pilot:
await pilot.press("r")
await pilot.pause()
assert app.return_value == 123