mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' of github.com:Textualize/textual into list-view
This commit is contained in:
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
**Please review the following checklist.**
|
||||
|
||||
- [ ] Docstrings on all new or modified functions / classes
|
||||
- [ ] Updated documentation
|
||||
- [ ] Updated CHANGELOG.md (where appropriate)
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.5.0] - Unreleased
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- Add easing parameter to Widget.scroll_* methods https://github.com/Textualize/textual/pull/1144
|
||||
- Added Widget.call_later which invokes a callback on idle.
|
||||
- `DOMNode.ancestors` no longer includes `self`.
|
||||
- Added `DOMNode.ancestors_with_self`, which retains the old behaviour of
|
||||
`DOMNode.ancestors`.
|
||||
- Improved the speed of `DOMQuery.remove`.
|
||||
- Added DataTable.clear
|
||||
- Added low-level `textual.walk` methods.
|
||||
- It is now possible to `await` a `Widget.remove`.
|
||||
https://github.com/Textualize/textual/issues/1094
|
||||
- It is now possible to `await` a `DOMQuery.remove`. Note that this changes
|
||||
the return value of `DOMQuery.remove`, which uses to return `self`.
|
||||
https://github.com/Textualize/textual/issues/1094
|
||||
- Added Pilot.wait_for_animation
|
||||
- Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121
|
||||
|
||||
### Changed
|
||||
|
||||
- Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145
|
||||
- Widget.call_later has been renamed to Widget.call_after_refresh.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026
|
||||
- Fixed issues with animation. Now objects of different types may be animated.
|
||||
- Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175
|
||||
|
||||
## [0.4.0] - 2022-11-08
|
||||
|
||||
https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
|
||||
|
||||
1
docs/api/walk.md
Normal file
1
docs/api/walk.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.walk
|
||||
@@ -23,7 +23,7 @@ class BindingApp(App):
|
||||
bar = Bar(color)
|
||||
bar.styles.background = Color.parse(color).with_alpha(0.5)
|
||||
self.mount(bar)
|
||||
self.call_later(self.screen.scroll_end, animate=False)
|
||||
self.call_after_refresh(self.screen.scroll_end, animate=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -46,6 +46,6 @@ width: auto
|
||||
|
||||
```python
|
||||
self.styles.height = 10
|
||||
self.styles.height = "50%
|
||||
self.styles.height = "50%"
|
||||
self.styles.height = "auto"
|
||||
```
|
||||
|
||||
15
mkdocs.yml
15
mkdocs.yml
@@ -118,6 +118,7 @@ nav:
|
||||
- "api/screen.md"
|
||||
- "api/static.md"
|
||||
- "api/timer.md"
|
||||
- "api/walk.md"
|
||||
- "api/widget.md"
|
||||
- "Blog":
|
||||
- blog/index.md
|
||||
@@ -211,3 +212,17 @@ plugins:
|
||||
|
||||
extra_css:
|
||||
- stylesheets/custom.css
|
||||
|
||||
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/twitter
|
||||
link: https://twitter.com/textualizeio
|
||||
name: textualizeio on Twitter
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/textualize/textual/
|
||||
name: Textual on Github
|
||||
- icon: fontawesome/brands/discord
|
||||
link: https://discord.gg/Enf6Z3qhVr
|
||||
name: Textual Discord server
|
||||
copyright: Copyright © Textualize, Inc
|
||||
|
||||
220
poetry.lock
generated
220
poetry.lock
generated
@@ -22,11 +22,11 @@ speedups = ["Brotli", "aiodns", "cchardet"]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.2.0"
|
||||
version = "1.3.1"
|
||||
description = "aiosignal: a list of registered asynchronous callbacks"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
frozenlist = ">=1.1.0"
|
||||
@@ -205,7 +205,7 @@ testing = ["covdefaults (>=2.2)", "coverage (>=6.4.2)", "pytest (>=7.1.2)", "pyt
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.3.1"
|
||||
version = "1.3.3"
|
||||
description = "A list-like structure which implements collections.abc.MutableSequence"
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -390,7 +390,7 @@ mkdocs = ">=1.1"
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-material"
|
||||
version = "8.5.8"
|
||||
version = "8.5.9"
|
||||
description = "Documentation that simply works"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -483,7 +483,7 @@ python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "0.982"
|
||||
version = "0.990"
|
||||
description = "Optional static typing for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -497,6 +497,7 @@ typing-extensions = ">=3.10"
|
||||
|
||||
[package.extras]
|
||||
dmypy = ["psutil (>=4.0)"]
|
||||
install-types = ["pip"]
|
||||
python2 = ["typed-ast (>=1.4.0,<2)"]
|
||||
reports = ["lxml"]
|
||||
|
||||
@@ -603,7 +604,7 @@ plugins = ["importlib-metadata"]
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "9.7"
|
||||
version = "9.8"
|
||||
description = "Extension pack for Python Markdown."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -794,7 +795,7 @@ python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "syrupy"
|
||||
version = "3.0.4"
|
||||
version = "3.0.5"
|
||||
description = "Pytest Snapshot Test Utility"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -928,7 +929,7 @@ dev = ["aiohttp", "click", "msgpack"]
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.7"
|
||||
content-hash = "9d355751c84f02b15b267922c96b2ca172dfe2e18e0afaf0d2e6794458ef5667"
|
||||
content-hash = "0c33f462f79d31068c0d74743f47c1d713ecc8ba009eaef1bef832f1272e4b1a"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = [
|
||||
@@ -1021,8 +1022,8 @@ aiohttp = [
|
||||
{file = "aiohttp-3.8.3.tar.gz", hash = "sha256:3828fb41b7203176b82fe5d699e0d845435f2374750a44b480ea6b930f6be269"},
|
||||
]
|
||||
aiosignal = [
|
||||
{file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"},
|
||||
{file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"},
|
||||
{file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
|
||||
{file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
|
||||
]
|
||||
async-timeout = [
|
||||
{file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
|
||||
@@ -1155,65 +1156,80 @@ filelock = [
|
||||
{file = "filelock-3.8.0.tar.gz", hash = "sha256:55447caa666f2198c5b6b13a26d2084d26fa5b115c00d065664b2124680c4edc"},
|
||||
]
|
||||
frozenlist = [
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5f271c93f001748fc26ddea409241312a75e13466b06c94798d1a341cf0e6989"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c6ef8014b842f01f5d2b55315f1af5cbfde284eb184075c189fd657c2fd8204"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:219a9676e2eae91cb5cc695a78b4cb43d8123e4160441d2b6ce8d2c70c60e2f3"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b47d64cdd973aede3dd71a9364742c542587db214e63b7529fbb487ed67cddd9"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2af6f7a4e93f5d08ee3f9152bce41a6015b5cf87546cb63872cc19b45476e98a"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a718b427ff781c4f4e975525edb092ee2cdef6a9e7bc49e15063b088961806f8"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c56c299602c70bc1bb5d1e75f7d8c007ca40c9d7aebaf6e4ba52925d88ef826d"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:717470bfafbb9d9be624da7780c4296aa7935294bd43a075139c3d55659038ca"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:31b44f1feb3630146cffe56344704b730c33e042ffc78d21f2125a6a91168131"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c3b31180b82c519b8926e629bf9f19952c743e089c41380ddca5db556817b221"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:d82bed73544e91fb081ab93e3725e45dd8515c675c0e9926b4e1f420a93a6ab9"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49459f193324fbd6413e8e03bd65789e5198a9fa3095e03f3620dee2f2dabff2"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:94e680aeedc7fd3b892b6fa8395b7b7cc4b344046c065ed4e7a1e390084e8cb5"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-win32.whl", hash = "sha256:fabb953ab913dadc1ff9dcc3a7a7d3dc6a92efab3a0373989b8063347f8705be"},
|
||||
{file = "frozenlist-1.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:eee0c5ecb58296580fc495ac99b003f64f82a74f9576a244d04978a7e97166db"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0bc75692fb3770cf2b5856a6c2c9de967ca744863c5e89595df64e252e4b3944"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086ca1ac0a40e722d6833d4ce74f5bf1aba2c77cbfdc0cd83722ffea6da52a04"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b51eb355e7f813bcda00276b0114c4172872dc5fb30e3fea059b9367c18fbcb"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74140933d45271c1a1283f708c35187f94e1256079b3c43f0c2267f9db5845ff"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee4c5120ddf7d4dd1eaf079af3af7102b56d919fa13ad55600a4e0ebe532779b"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97d9e00f3ac7c18e685320601f91468ec06c58acc185d18bb8e511f196c8d4b2"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6e19add867cebfb249b4e7beac382d33215d6d54476bb6be46b01f8cafb4878b"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a027f8f723d07c3f21963caa7d585dcc9b089335565dabe9c814b5f70c52705a"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:61d7857950a3139bce035ad0b0945f839532987dfb4c06cfe160254f4d19df03"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:53b2b45052e7149ee8b96067793db8ecc1ae1111f2f96fe1f88ea5ad5fd92d10"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bbb1a71b1784e68870800b1bc9f3313918edc63dbb8f29fbd2e767ce5821696c"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-win32.whl", hash = "sha256:ab6fa8c7871877810e1b4e9392c187a60611fbf0226a9e0b11b7b92f5ac72792"},
|
||||
{file = "frozenlist-1.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f89139662cc4e65a4813f4babb9ca9544e42bddb823d2ec434e18dad582543bc"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:4c0c99e31491a1d92cde8648f2e7ccad0e9abb181f6ac3ddb9fc48b63301808e"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:61e8cb51fba9f1f33887e22488bad1e28dd8325b72425f04517a4d285a04c519"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc2f3e368ee5242a2cbe28323a866656006382872c40869b49b265add546703f"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58fb94a01414cddcdc6839807db77ae8057d02ddafc94a42faee6004e46c9ba8"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:022178b277cb9277d7d3b3f2762d294f15e85cd2534047e68a118c2bb0058f3e"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:572ce381e9fe027ad5e055f143763637dcbac2542cfe27f1d688846baeef5170"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19127f8dcbc157ccb14c30e6f00392f372ddb64a6ffa7106b26ff2196477ee9f"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42719a8bd3792744c9b523674b752091a7962d0d2d117f0b417a3eba97d1164b"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2743bb63095ef306041c8f8ea22bd6e4d91adabf41887b1ad7886c4c1eb43d5f"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fa47319a10e0a076709644a0efbcaab9e91902c8bd8ef74c6adb19d320f69b83"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52137f0aea43e1993264a5180c467a08a3e372ca9d378244c2d86133f948b26b"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:f5abc8b4d0c5b556ed8cd41490b606fe99293175a82b98e652c3f2711b452988"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1e1cf7bc8cbbe6ce3881863671bac258b7d6bfc3706c600008925fb799a256e2"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-win32.whl", hash = "sha256:0dde791b9b97f189874d654c55c24bf7b6782343e14909c84beebd28b7217845"},
|
||||
{file = "frozenlist-1.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:9494122bf39da6422b0972c4579e248867b6b1b50c9b05df7e04a3f30b9a413d"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31bf9539284f39ff9398deabf5561c2b0da5bb475590b4e13dd8b268d7a3c5c1"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e0c8c803f2f8db7217898d11657cb6042b9b0553a997c4a0601f48a691480fab"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da5ba7b59d954f1f214d352308d1d86994d713b13edd4b24a556bcc43d2ddbc3"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74e6b2b456f21fc93ce1aff2b9728049f1464428ee2c9752a4b4f61e98c4db96"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526d5f20e954d103b1d47232e3839f3453c02077b74203e43407b962ab131e7b"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b499c6abe62a7a8d023e2c4b2834fce78a6115856ae95522f2f974139814538c"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab386503f53bbbc64d1ad4b6865bf001414930841a870fc97f1546d4d133f141"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f63c308f82a7954bf8263a6e6de0adc67c48a8b484fab18ff87f349af356efd"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:12607804084d2244a7bd4685c9d0dca5df17a6a926d4f1967aa7978b1028f89f"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:da1cdfa96425cbe51f8afa43e392366ed0b36ce398f08b60de6b97e3ed4affef"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f810e764617b0748b49a731ffaa525d9bb36ff38332411704c2400125af859a6"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:35c3d79b81908579beb1fb4e7fcd802b7b4921f1b66055af2578ff7734711cfa"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c92deb5d9acce226a501b77307b3b60b264ca21862bd7d3e0c1f3594022f01bc"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-win32.whl", hash = "sha256:5e77a8bd41e54b05e4fb2708dc6ce28ee70325f8c6f50f3df86a44ecb1d7a19b"},
|
||||
{file = "frozenlist-1.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:625d8472c67f2d96f9a4302a947f92a7adbc1e20bedb6aff8dbc8ff039ca6189"},
|
||||
{file = "frozenlist-1.3.1.tar.gz", hash = "sha256:3a735e4211a04ccfa3f4833547acdf5d2f863bfeb01cfd3edaffbc251f15cec8"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b1c63e8d377d039ac769cd0926558bb7068a1f7abb0f003e3717ee003ad85530"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fdfc24dcfce5b48109867c13b4cb15e4660e7bd7661741a391f821f23dfdca7"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c926450857408e42f0bbc295e84395722ce74bae69a3b2aa2a65fe22cb14b99"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1841e200fdafc3d51f974d9d377c079a0694a8f06de2e67b48150328d66d5483"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f470c92737afa7d4c3aacc001e335062d582053d4dbe73cda126f2d7031068dd"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:783263a4eaad7c49983fe4b2e7b53fa9770c136c270d2d4bbb6d2192bf4d9caf"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:924620eef691990dfb56dc4709f280f40baee568c794b5c1885800c3ecc69816"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ae4dc05c465a08a866b7a1baf360747078b362e6a6dbeb0c57f234db0ef88ae0"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:bed331fe18f58d844d39ceb398b77d6ac0b010d571cba8267c2e7165806b00ce"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:02c9ac843e3390826a265e331105efeab489ffaf4dd86384595ee8ce6d35ae7f"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9545a33965d0d377b0bc823dcabf26980e77f1b6a7caa368a365a9497fb09420"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-win32.whl", hash = "sha256:d5cd3ab21acbdb414bb6c31958d7b06b85eeb40f66463c264a9b343a4e238642"},
|
||||
{file = "frozenlist-1.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b756072364347cb6aa5b60f9bc18e94b2f79632de3b0190253ad770c5df17db1"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4395e2f8d83fbe0c627b2b696acce67868793d7d9750e90e39592b3626691b7"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14143ae966a6229350021384870458e4777d1eae4c28d1a7aa47f24d030e6678"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d8860749e813a6f65bad8285a0520607c9500caa23fea6ee407e63debcdbef6"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23d16d9f477bb55b6154654e0e74557040575d9d19fe78a161bd33d7d76808e8"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb82dbba47a8318e75f679690190c10a5e1f447fbf9df41cbc4c3afd726d88cb"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9309869032abb23d196cb4e4db574232abe8b8be1339026f489eeb34a4acfd91"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a97b4fe50b5890d36300820abd305694cb865ddb7885049587a5678215782a6b"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c188512b43542b1e91cadc3c6c915a82a5eb95929134faf7fd109f14f9892ce4"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:303e04d422e9b911a09ad499b0368dc551e8c3cd15293c99160c7f1f07b59a48"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0771aed7f596c7d73444c847a1c16288937ef988dc04fb9f7be4b2aa91db609d"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:66080ec69883597e4d026f2f71a231a1ee9887835902dbe6b6467d5a89216cf6"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:41fe21dc74ad3a779c3d73a2786bdf622ea81234bdd4faf90b8b03cad0c2c0b4"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f20380df709d91525e4bee04746ba612a4df0972c1b8f8e1e8af997e678c7b81"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-win32.whl", hash = "sha256:f30f1928162e189091cf4d9da2eac617bfe78ef907a761614ff577ef4edfb3c8"},
|
||||
{file = "frozenlist-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:a6394d7dadd3cfe3f4b3b186e54d5d8504d44f2d58dcc89d693698e8b7132b32"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8df3de3a9ab8325f94f646609a66cbeeede263910c5c0de0101079ad541af332"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0693c609e9742c66ba4870bcee1ad5ff35462d5ffec18710b4ac89337ff16e27"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd4210baef299717db0a600d7a3cac81d46ef0e007f88c9335db79f8979c0d3d"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:394c9c242113bfb4b9aa36e2b80a05ffa163a30691c7b5a29eba82e937895d5e"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6327eb8e419f7d9c38f333cde41b9ae348bec26d840927332f17e887a8dcb70d"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e24900aa13212e75e5b366cb9065e78bbf3893d4baab6052d1aca10d46d944c"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3843f84a6c465a36559161e6c59dce2f2ac10943040c2fd021cfb70d58c4ad56"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:84610c1502b2461255b4c9b7d5e9c48052601a8957cd0aea6ec7a7a1e1fb9420"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:c21b9aa40e08e4f63a2f92ff3748e6b6c84d717d033c7b3438dd3123ee18f70e"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:efce6ae830831ab6a22b9b4091d411698145cb9b8fc869e1397ccf4b4b6455cb"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:40de71985e9042ca00b7953c4f41eabc3dc514a2d1ff534027f091bc74416401"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-win32.whl", hash = "sha256:180c00c66bde6146a860cbb81b54ee0df350d2daf13ca85b275123bbf85de18a"},
|
||||
{file = "frozenlist-1.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9bbbcedd75acdfecf2159663b87f1bb5cfc80e7cd99f7ddd9d66eb98b14a8411"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:034a5c08d36649591be1cbb10e09da9f531034acfe29275fc5454a3b101ce41a"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba64dc2b3b7b158c6660d49cdb1d872d1d0bf4e42043ad8d5006099479a194e5"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:47df36a9fe24054b950bbc2db630d508cca3aa27ed0566c0baf661225e52c18e"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:008a054b75d77c995ea26629ab3a0c0d7281341f2fa7e1e85fa6153ae29ae99c"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:841ea19b43d438a80b4de62ac6ab21cfe6827bb8a9dc62b896acc88eaf9cecba"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e235688f42b36be2b6b06fc37ac2126a73b75fb8d6bc66dd632aa35286238703"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca713d4af15bae6e5d79b15c10c8522859a9a89d3b361a50b817c98c2fb402a2"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ac5995f2b408017b0be26d4a1d7c61bce106ff3d9e3324374d66b5964325448"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4ae8135b11652b08a8baf07631d3ebfe65a4c87909dbef5fa0cdde440444ee4"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4ea42116ceb6bb16dbb7d526e242cb6747b08b7710d9782aa3d6732bd8d27649"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:810860bb4bdce7557bc0febb84bbd88198b9dbc2022d8eebe5b3590b2ad6c842"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ee78feb9d293c323b59a6f2dd441b63339a30edf35abcb51187d2fc26e696d13"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0af2e7c87d35b38732e810befb9d797a99279cbb85374d42ea61c1e9d23094b3"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-win32.whl", hash = "sha256:899c5e1928eec13fd6f6d8dc51be23f0d09c5281e40d9cf4273d188d9feeaf9b"},
|
||||
{file = "frozenlist-1.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:7f44e24fa70f6fbc74aeec3e971f60a14dde85da364aa87f15d1be94ae75aeef"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2b07ae0c1edaa0a36339ec6cce700f51b14a3fc6545fdd32930d2c83917332cf"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb86518203e12e96af765ee89034a1dbb0c3c65052d1b0c19bbbd6af8a145e1"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5cf820485f1b4c91e0417ea0afd41ce5cf5965011b3c22c400f6d144296ccbc0"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c11e43016b9024240212d2a65043b70ed8dfd3b52678a1271972702d990ac6d"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa3c6e3305aa1146b59a09b32b2e04074945ffcfb2f0931836d103a2c38f936"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:352bd4c8c72d508778cf05ab491f6ef36149f4d0cb3c56b1b4302852255d05d5"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65a5e4d3aa679610ac6e3569e865425b23b372277f89b5ef06cf2cdaf1ebf22b"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e2c1185858d7e10ff045c496bbf90ae752c28b365fef2c09cf0fa309291669"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f163d2fd041c630fed01bc48d28c3ed4a3b003c00acd396900e11ee5316b56bb"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:05cdb16d09a0832eedf770cb7bd1fe57d8cf4eaf5aced29c4e41e3f20b30a784"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:8bae29d60768bfa8fb92244b74502b18fae55a80eac13c88eb0b496d4268fd2d"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:eedab4c310c0299961ac285591acd53dc6723a1ebd90a57207c71f6e0c2153ab"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3bbdf44855ed8f0fbcd102ef05ec3012d6a4fd7c7562403f76ce6a52aeffb2b1"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-win32.whl", hash = "sha256:efa568b885bca461f7c7b9e032655c0c143d305bf01c30caf6db2854a4532b38"},
|
||||
{file = "frozenlist-1.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:cfe33efc9cb900a4c46f91a5ceba26d6df370ffddd9ca386eb1d4f0ad97b9ea9"},
|
||||
{file = "frozenlist-1.3.3.tar.gz", hash = "sha256:58bcc55721e8a90b88332d6cd441261ebb22342e238296bb330968952fbb3a6a"},
|
||||
]
|
||||
ghp-import = [
|
||||
{file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"},
|
||||
@@ -1310,8 +1326,8 @@ mkdocs-autorefs = [
|
||||
{file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"},
|
||||
]
|
||||
mkdocs-material = [
|
||||
{file = "mkdocs_material-8.5.8-py3-none-any.whl", hash = "sha256:7ff092299e3a63cef99cd87e4a6cc7e7d9ec31fd190d766fd147c35572e6d593"},
|
||||
{file = "mkdocs_material-8.5.8.tar.gz", hash = "sha256:61396251819cf7f547f70a09ce6a7edb2ff5d32e47b9199769020b2d20a83d44"},
|
||||
{file = "mkdocs_material-8.5.9-py3-none-any.whl", hash = "sha256:143ea55843b3747b640e1110824d91e8a4c670352380e166e64959f9abe98862"},
|
||||
{file = "mkdocs_material-8.5.9.tar.gz", hash = "sha256:45eeabb23d2caba8fa3b85c91d9ec8e8b22add716e9bba8faf16d56af8aa5622"},
|
||||
]
|
||||
mkdocs-material-extensions = [
|
||||
{file = "mkdocs_material_extensions-1.1-py3-none-any.whl", hash = "sha256:bcc2e5fc70c0ec50e59703ee6e639d87c7e664c0c441c014ea84461a90f1e902"},
|
||||
@@ -1445,30 +1461,36 @@ multidict = [
|
||||
{file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
|
||||
]
|
||||
mypy = [
|
||||
{file = "mypy-0.982-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5085e6f442003fa915aeb0a46d4da58128da69325d8213b4b35cc7054090aed5"},
|
||||
{file = "mypy-0.982-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:41fd1cf9bc0e1c19b9af13a6580ccb66c381a5ee2cf63ee5ebab747a4badeba3"},
|
||||
{file = "mypy-0.982-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f793e3dd95e166b66d50e7b63e69e58e88643d80a3dcc3bcd81368e0478b089c"},
|
||||
{file = "mypy-0.982-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86ebe67adf4d021b28c3f547da6aa2cce660b57f0432617af2cca932d4d378a6"},
|
||||
{file = "mypy-0.982-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:175f292f649a3af7082fe36620369ffc4661a71005aa9f8297ea473df5772046"},
|
||||
{file = "mypy-0.982-cp310-cp310-win_amd64.whl", hash = "sha256:8ee8c2472e96beb1045e9081de8e92f295b89ac10c4109afdf3a23ad6e644f3e"},
|
||||
{file = "mypy-0.982-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58f27ebafe726a8e5ccb58d896451dd9a662a511a3188ff6a8a6a919142ecc20"},
|
||||
{file = "mypy-0.982-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6af646bd46f10d53834a8e8983e130e47d8ab2d4b7a97363e35b24e1d588947"},
|
||||
{file = "mypy-0.982-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e7aeaa763c7ab86d5b66ff27f68493d672e44c8099af636d433a7f3fa5596d40"},
|
||||
{file = "mypy-0.982-cp37-cp37m-win_amd64.whl", hash = "sha256:724d36be56444f569c20a629d1d4ee0cb0ad666078d59bb84f8f887952511ca1"},
|
||||
{file = "mypy-0.982-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:14d53cdd4cf93765aa747a7399f0961a365bcddf7855d9cef6306fa41de01c24"},
|
||||
{file = "mypy-0.982-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:26ae64555d480ad4b32a267d10cab7aec92ff44de35a7cd95b2b7cb8e64ebe3e"},
|
||||
{file = "mypy-0.982-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6389af3e204975d6658de4fb8ac16f58c14e1bacc6142fee86d1b5b26aa52bda"},
|
||||
{file = "mypy-0.982-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b35ce03a289480d6544aac85fa3674f493f323d80ea7226410ed065cd46f206"},
|
||||
{file = "mypy-0.982-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c6e564f035d25c99fd2b863e13049744d96bd1947e3d3d2f16f5828864506763"},
|
||||
{file = "mypy-0.982-cp38-cp38-win_amd64.whl", hash = "sha256:cebca7fd333f90b61b3ef7f217ff75ce2e287482206ef4a8b18f32b49927b1a2"},
|
||||
{file = "mypy-0.982-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a705a93670c8b74769496280d2fe6cd59961506c64f329bb179970ff1d24f9f8"},
|
||||
{file = "mypy-0.982-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75838c649290d83a2b83a88288c1eb60fe7a05b36d46cbea9d22efc790002146"},
|
||||
{file = "mypy-0.982-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:91781eff1f3f2607519c8b0e8518aad8498af1419e8442d5d0afb108059881fc"},
|
||||
{file = "mypy-0.982-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaa97b9ddd1dd9901a22a879491dbb951b5dec75c3b90032e2baa7336777363b"},
|
||||
{file = "mypy-0.982-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a692a8e7d07abe5f4b2dd32d731812a0175626a90a223d4b58f10f458747dd8a"},
|
||||
{file = "mypy-0.982-cp39-cp39-win_amd64.whl", hash = "sha256:eb7a068e503be3543c4bd329c994103874fa543c1727ba5288393c21d912d795"},
|
||||
{file = "mypy-0.982-py3-none-any.whl", hash = "sha256:1021c241e8b6e1ca5a47e4d52601274ac078a89845cfde66c6d5f769819ffa1d"},
|
||||
{file = "mypy-0.982.tar.gz", hash = "sha256:85f7a343542dc8b1ed0a888cdd34dca56462654ef23aa673907305b260b3d746"},
|
||||
{file = "mypy-0.990-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aaf1be63e0207d7d17be942dcf9a6b641745581fe6c64df9a38deb562a7dbafa"},
|
||||
{file = "mypy-0.990-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d555aa7f44cecb7ea3c0ac69d58b1a5afb92caa017285a8e9c4efbf0518b61b4"},
|
||||
{file = "mypy-0.990-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f694d6d09a460b117dccb6857dda269188e3437c880d7b60fa0014fa872d1e9"},
|
||||
{file = "mypy-0.990-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:269f0dfb6463b8780333310ff4b5134425157ef0d2b1d614015adaf6d6a7eabd"},
|
||||
{file = "mypy-0.990-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8798c8ed83aa809f053abff08664bdca056038f5a02af3660de00b7290b64c47"},
|
||||
{file = "mypy-0.990-cp310-cp310-win_amd64.whl", hash = "sha256:47a9955214615108c3480a500cfda8513a0b1cd3c09a1ed42764ca0dd7b931dd"},
|
||||
{file = "mypy-0.990-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4a8a6c10f4c63fbf6ad6c03eba22c9331b3946a4cec97f008e9ffb4d3b31e8e2"},
|
||||
{file = "mypy-0.990-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cd2dd3730ba894ec2a2082cc703fbf3e95a08479f7be84912e3131fc68809d46"},
|
||||
{file = "mypy-0.990-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7da0005e47975287a92b43276e460ac1831af3d23032c34e67d003388a0ce8d0"},
|
||||
{file = "mypy-0.990-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:262c543ef24deb10470a3c1c254bb986714e2b6b1a67d66daf836a548a9f316c"},
|
||||
{file = "mypy-0.990-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3ff201a0c6d3ea029d73b1648943387d75aa052491365b101f6edd5570d018ea"},
|
||||
{file = "mypy-0.990-cp311-cp311-win_amd64.whl", hash = "sha256:1767830da2d1afa4e62b684647af0ff79b401f004d7fa08bc5b0ce2d45bcd5ec"},
|
||||
{file = "mypy-0.990-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6826d9c4d85bbf6d68cb279b561de6a4d8d778ca8e9ab2d00ee768ab501a9852"},
|
||||
{file = "mypy-0.990-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46897755f944176fbc504178422a5a2875bbf3f7436727374724842c0987b5af"},
|
||||
{file = "mypy-0.990-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0680389c34284287fe00e82fc8bccdea9aff318f7e7d55b90d967a13a9606013"},
|
||||
{file = "mypy-0.990-cp37-cp37m-win_amd64.whl", hash = "sha256:b08541a06eed35b543ae1a6b301590eb61826a1eb099417676ddc5a42aa151c5"},
|
||||
{file = "mypy-0.990-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:be88d665e76b452c26fb2bdc3d54555c01226fba062b004ede780b190a50f9db"},
|
||||
{file = "mypy-0.990-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b8f4a8213b1fd4b751e26b59ae0e0c12896568d7e805861035c7a15ed6dc9eb"},
|
||||
{file = "mypy-0.990-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2b6f85c2ad378e3224e017904a051b26660087b3b76490d533b7344f1546d3ff"},
|
||||
{file = "mypy-0.990-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ee5f99817ee70254e7eb5cf97c1b11dda29c6893d846c8b07bce449184e9466"},
|
||||
{file = "mypy-0.990-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49082382f571c3186ce9ea0bd627cb1345d4da8d44a8377870f4442401f0a706"},
|
||||
{file = "mypy-0.990-cp38-cp38-win_amd64.whl", hash = "sha256:aba38e3dd66bdbafbbfe9c6e79637841928ea4c79b32e334099463c17b0d90ef"},
|
||||
{file = "mypy-0.990-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9d851c09b981a65d9d283a8ccb5b1d0b698e580493416a10942ef1a04b19fd37"},
|
||||
{file = "mypy-0.990-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d847dd23540e2912d9667602271e5ebf25e5788e7da46da5ffd98e7872616e8e"},
|
||||
{file = "mypy-0.990-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc6019808580565040cd2a561b593d7c3c646badd7e580e07d875eb1bf35c695"},
|
||||
{file = "mypy-0.990-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a3150d409609a775c8cb65dbe305c4edd7fe576c22ea79d77d1454acd9aeda8"},
|
||||
{file = "mypy-0.990-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3227f14fe943524f5794679156488f18bf8d34bfecd4623cf76bc55958d229c5"},
|
||||
{file = "mypy-0.990-cp39-cp39-win_amd64.whl", hash = "sha256:c76c769c46a1e6062a84837badcb2a7b0cdb153d68601a61f60739c37d41cc74"},
|
||||
{file = "mypy-0.990-py3-none-any.whl", hash = "sha256:8f1940325a8ed460ba03d19ab83742260fa9534804c317224e5d4e5aa588e2d6"},
|
||||
{file = "mypy-0.990.tar.gz", hash = "sha256:72382cb609142dba3f04140d016c94b4092bc7b4d98ca718740dc989e5271b8d"},
|
||||
]
|
||||
mypy-extensions = [
|
||||
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
|
||||
@@ -1507,8 +1529,8 @@ pygments = [
|
||||
{file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"},
|
||||
]
|
||||
pymdown-extensions = [
|
||||
{file = "pymdown_extensions-9.7-py3-none-any.whl", hash = "sha256:767d07d9dead0f52f5135545c01f4ed627f9a7918ee86c646d893e24c59db87d"},
|
||||
{file = "pymdown_extensions-9.7.tar.gz", hash = "sha256:651b0107bc9ee790aedea3673cb88832c0af27d2569cf45c2de06f1d65292e96"},
|
||||
{file = "pymdown_extensions-9.8-py3-none-any.whl", hash = "sha256:8e62688a8b1128acd42fa823f3d429d22f4284b5e6dd4d3cd56721559a5a211b"},
|
||||
{file = "pymdown_extensions-9.8.tar.gz", hash = "sha256:1bd4a173095ef8c433b831af1f3cb13c10883be0c100ae613560668e594651f7"},
|
||||
]
|
||||
pyparsing = [
|
||||
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
|
||||
@@ -1605,8 +1627,8 @@ smmap = [
|
||||
{file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"},
|
||||
]
|
||||
syrupy = [
|
||||
{file = "syrupy-3.0.4-py3-none-any.whl", hash = "sha256:85c4f5c51618eefab02e60e0172664a22987f20ea17efa815c4832cd64822fc6"},
|
||||
{file = "syrupy-3.0.4.tar.gz", hash = "sha256:cbb1e28149340e31a01d3644a234d3195a4b6806c7b8c18e4930ca9add5c6af1"},
|
||||
{file = "syrupy-3.0.5-py3-none-any.whl", hash = "sha256:6dc0472cc690782b517d509a2854b5ca552a9851acf43b8a847cfa1aed6f6b01"},
|
||||
{file = "syrupy-3.0.5.tar.gz", hash = "sha256:928962a6c14abb2695be9a49b42a016a4e4abdb017a69104cde958f2faf01f98"},
|
||||
]
|
||||
time-machine = [
|
||||
{file = "time-machine-2.8.2.tar.gz", hash = "sha256:2ff3cd145c381ac87b1c35400475a8f019b15dc2267861aad0466f55b49e7813"},
|
||||
|
||||
@@ -47,7 +47,7 @@ dev = ["aiohttp", "click", "msgpack"]
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.1.3"
|
||||
black = "^22.3.0"
|
||||
mypy = "^0.982"
|
||||
mypy = "^0.990"
|
||||
pytest-cov = "^2.12.1"
|
||||
mkdocs = "^1.3.0"
|
||||
mkdocstrings = {extras = ["python"], version = "^0.19.0"}
|
||||
|
||||
@@ -5,7 +5,7 @@ import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Any, Callable, TypeVar, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
||||
|
||||
from . import _clock
|
||||
from ._callback import invoke
|
||||
@@ -23,6 +23,11 @@ if TYPE_CHECKING:
|
||||
|
||||
EasingFunction = Callable[[float], float]
|
||||
|
||||
|
||||
class AnimationError(Exception):
|
||||
"""An issue prevented animation from starting."""
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -118,7 +123,7 @@ class BoundAnimator:
|
||||
def __call__(
|
||||
self,
|
||||
attribute: str,
|
||||
value: float | Animatable,
|
||||
value: str | float | Animatable,
|
||||
*,
|
||||
final_value: object = ...,
|
||||
duration: float | None = None,
|
||||
@@ -140,6 +145,12 @@ class BoundAnimator:
|
||||
on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None.
|
||||
|
||||
"""
|
||||
start_value = getattr(self._obj, attribute)
|
||||
if isinstance(value, str) and hasattr(start_value, "parse"):
|
||||
# Color and Scalar have a parse method
|
||||
# I'm exploiting a coincidence here, but I think this should be a first-class concept
|
||||
# TODO: add a `Parsable` protocol
|
||||
value = start_value.parse(value)
|
||||
easing_function = EASING[easing] if isinstance(easing, str) else easing
|
||||
return self._animator.animate(
|
||||
self._obj,
|
||||
@@ -181,6 +192,8 @@ class Animator:
|
||||
await self._timer.stop()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self._idle_event.set()
|
||||
|
||||
def bind(self, obj: object) -> BoundAnimator:
|
||||
"""Bind the animator to a given objects."""
|
||||
@@ -270,9 +283,11 @@ class Animator:
|
||||
easing_function = EASING[easing] if isinstance(easing, str) else easing
|
||||
|
||||
animation: Animation | None = None
|
||||
|
||||
if hasattr(obj, "__textual_animation__"):
|
||||
animation = getattr(obj, "__textual_animation__")(
|
||||
attribute,
|
||||
getattr(obj, attribute),
|
||||
value,
|
||||
start_time,
|
||||
duration=duration,
|
||||
@@ -280,7 +295,17 @@ class Animator:
|
||||
easing=easing_function,
|
||||
on_complete=on_complete,
|
||||
)
|
||||
|
||||
if animation is None:
|
||||
|
||||
if not isinstance(value, (int, float)) and not isinstance(
|
||||
value, Animatable
|
||||
):
|
||||
raise AnimationError(
|
||||
f"Don't know how to animate {value!r}; "
|
||||
"Can only animate <int>, <float>, or objects with a blend method"
|
||||
)
|
||||
|
||||
start_value = getattr(obj, attribute)
|
||||
|
||||
if start_value == value:
|
||||
|
||||
@@ -3,9 +3,7 @@ from __future__ import annotations
|
||||
from functools import lru_cache
|
||||
from typing import cast, Tuple, Union
|
||||
|
||||
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
|
||||
import rich.repr
|
||||
from rich.segment import Segment, SegmentLines
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from .color import Color
|
||||
@@ -158,164 +156,6 @@ def render_row(
|
||||
return [Segment(box2.text * width, box2.style)]
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Border:
|
||||
"""Renders Textual CSS borders.
|
||||
|
||||
This is analogous to Rich's `Box` but more flexible. Different borders may be
|
||||
applied to each of the four edges, and more advanced borders can be achieved through
|
||||
various combinations of Widget and parent background colors.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
renderable: RenderableType,
|
||||
borders: Borders,
|
||||
inner_color: Color,
|
||||
outer_color: Color,
|
||||
outline: bool = False,
|
||||
):
|
||||
self.renderable = renderable
|
||||
self.edge_styles = borders
|
||||
self.outline = outline
|
||||
|
||||
(
|
||||
(top, top_color),
|
||||
(right, right_color),
|
||||
(bottom, bottom_color),
|
||||
(left, left_color),
|
||||
) = borders
|
||||
self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType]
|
||||
self._sides = (top, right, bottom, left)
|
||||
from_color = Style.from_color
|
||||
|
||||
self._styles = (
|
||||
from_color(top_color.rich_color),
|
||||
from_color(right_color.rich_color),
|
||||
from_color(bottom_color.rich_color),
|
||||
from_color(left_color.rich_color),
|
||||
)
|
||||
self.inner_style = from_color(bgcolor=inner_color.rich_color)
|
||||
self.outer_style = from_color(bgcolor=outer_color.rich_color)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self.renderable
|
||||
yield self.edge_styles
|
||||
|
||||
def _crop_renderable(self, lines: list[list[Segment]], width: int) -> None:
|
||||
"""Crops a renderable in place.
|
||||
|
||||
Args:
|
||||
lines (list[list[Segment]]): Segment lines.
|
||||
width (int): Desired width.
|
||||
"""
|
||||
top, right, bottom, left = self._sides
|
||||
# the 4 following lines rely on the fact that we normalise "none" and "hidden" to en empty string
|
||||
has_left = bool(left)
|
||||
has_right = bool(right)
|
||||
has_top = bool(top)
|
||||
has_bottom = bool(bottom)
|
||||
|
||||
if has_top:
|
||||
lines.pop(0)
|
||||
if has_bottom and lines:
|
||||
lines.pop(-1)
|
||||
|
||||
# TODO: Divide is probably quite inefficient here,
|
||||
# It could be much faster for the specific case of one off the start end end
|
||||
divide = Segment.divide
|
||||
if has_left and has_right:
|
||||
for line in lines:
|
||||
_, line[:] = divide(line, [1, width - 1])
|
||||
elif has_left:
|
||||
for line in lines:
|
||||
_, line[:] = divide(line, [1, width])
|
||||
elif has_right:
|
||||
for line in lines:
|
||||
line[:], _ = divide(line, [width - 1, width])
|
||||
|
||||
def __rich_console__(
|
||||
self, console: "Console", options: "ConsoleOptions"
|
||||
) -> "RenderResult":
|
||||
top, right, bottom, left = self._sides
|
||||
style = console.get_style(self.inner_style)
|
||||
outer_style = console.get_style(self.outer_style)
|
||||
top_style, right_style, bottom_style, left_style = self._styles
|
||||
|
||||
# ditto than in `_crop_renderable` ☝
|
||||
has_left = bool(left)
|
||||
has_right = bool(right)
|
||||
has_top = bool(top)
|
||||
has_bottom = bool(bottom)
|
||||
|
||||
width = options.max_width - has_left - has_right
|
||||
|
||||
if width <= 2:
|
||||
lines = console.render_lines(self.renderable, options, new_lines=True)
|
||||
yield SegmentLines(lines)
|
||||
return
|
||||
|
||||
if self.outline:
|
||||
render_options = options
|
||||
else:
|
||||
if options.height is None:
|
||||
render_options = options.update_width(width)
|
||||
else:
|
||||
new_height = options.height - has_top - has_bottom
|
||||
if new_height >= 1:
|
||||
render_options = options.update_dimensions(width, new_height)
|
||||
else:
|
||||
render_options = options.update_width(width)
|
||||
|
||||
lines = console.render_lines(self.renderable, render_options)
|
||||
if self.outline:
|
||||
self._crop_renderable(lines, options.max_width)
|
||||
|
||||
_Segment = Segment
|
||||
new_line = _Segment.line()
|
||||
if has_top:
|
||||
box1, box2, box3 = get_box(top, style, outer_style, top_style)[0]
|
||||
if has_left:
|
||||
yield box1 if top == left else _Segment(" ", box2.style)
|
||||
yield _Segment(box2.text * width, box2.style)
|
||||
if has_right:
|
||||
yield box3 if top == left else _Segment(" ", box3.style)
|
||||
yield new_line
|
||||
|
||||
left_segment = get_box(left, style, outer_style, left_style)[1][0]
|
||||
_right_segment = get_box(right, style, outer_style, right_style)[1][2]
|
||||
right_segment = _Segment(_right_segment.text + "\n", _right_segment.style)
|
||||
|
||||
if has_left and has_right:
|
||||
for line in lines:
|
||||
yield left_segment
|
||||
yield from line
|
||||
yield right_segment
|
||||
elif has_left:
|
||||
for line in lines:
|
||||
yield left_segment
|
||||
yield from line
|
||||
yield new_line
|
||||
elif has_right:
|
||||
for line in lines:
|
||||
yield from line
|
||||
yield right_segment
|
||||
else:
|
||||
for line in lines:
|
||||
yield from line
|
||||
yield new_line
|
||||
|
||||
if has_bottom:
|
||||
box1, box2, box3 = get_box(bottom, style, outer_style, bottom_style)[2]
|
||||
if has_left:
|
||||
yield box1 if bottom == left else _Segment(" ", box1.style)
|
||||
yield _Segment(box2.text * width, box2.style)
|
||||
if has_right:
|
||||
yield box3 if bottom == right else _Segment(" ", box3.style)
|
||||
yield new_line
|
||||
|
||||
|
||||
_edge_type_normalization_table: dict[EdgeType, EdgeType] = {
|
||||
# i.e. we normalize "border: none;" to "border: ;".
|
||||
# As a result our layout-related calculations that include borders are simpler (and have better performance)
|
||||
@@ -326,49 +166,3 @@ _edge_type_normalization_table: dict[EdgeType, EdgeType] = {
|
||||
|
||||
def normalize_border_value(value: BorderValue) -> BorderValue:
|
||||
return _edge_type_normalization_table.get(value[0], value[0]), value[1]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich import print
|
||||
from rich.text import Text
|
||||
from rich.padding import Padding
|
||||
|
||||
from .color import Color
|
||||
|
||||
inner = Color.parse("#303F9F")
|
||||
outer = Color.parse("#212121")
|
||||
|
||||
lorem = """[#C5CAE9]Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus."""
|
||||
text = Text.from_markup(lorem)
|
||||
border = Border(
|
||||
Padding(text, 1, style="on #303F9F"),
|
||||
(
|
||||
("none", Color.parse("#C5CAE9")),
|
||||
("none", Color.parse("#C5CAE9")),
|
||||
("wide", Color.parse("#C5CAE9")),
|
||||
("none", Color.parse("#C5CAE9")),
|
||||
),
|
||||
inner_color=inner,
|
||||
outer_color=outer,
|
||||
)
|
||||
|
||||
print(
|
||||
Padding(border, (1, 2), style="on #212121"),
|
||||
)
|
||||
print()
|
||||
|
||||
border = Border(
|
||||
Padding(text, 1, style="on #303F9F"),
|
||||
(
|
||||
("hkey", Color.parse("#8BC34A")),
|
||||
("hkey", Color.parse("#8BC34A")),
|
||||
("hkey", Color.parse("#8BC34A")),
|
||||
("hkey", Color.parse("#8BC34A")),
|
||||
),
|
||||
inner_color=inner,
|
||||
outer_color=outer,
|
||||
)
|
||||
|
||||
print(
|
||||
Padding(border, (1, 2), style="on #212121"),
|
||||
)
|
||||
|
||||
@@ -633,11 +633,7 @@ class Compositor:
|
||||
def is_visible(widget: Widget) -> bool:
|
||||
"""Return True if the widget is (literally) visible by examining various
|
||||
properties which affect whether it can be seen or not."""
|
||||
return (
|
||||
widget.visible
|
||||
and not widget.is_transparent
|
||||
and widget.styles.opacity > 0
|
||||
)
|
||||
return widget.visible and widget.styles.opacity > 0
|
||||
|
||||
_Region = Region
|
||||
|
||||
|
||||
@@ -128,3 +128,4 @@ EASING = {
|
||||
}
|
||||
|
||||
DEFAULT_EASING = "in_out_cubic"
|
||||
DEFAULT_SCROLL_EASING = "out_cubic"
|
||||
|
||||
@@ -43,7 +43,7 @@ def resolve(
|
||||
(
|
||||
(scalar, None)
|
||||
if scalar.is_fraction
|
||||
else (scalar, scalar.resolve_dimension(size, viewport))
|
||||
else (scalar, scalar.resolve(size, viewport))
|
||||
)
|
||||
for scalar in dimensions
|
||||
]
|
||||
|
||||
@@ -53,7 +53,7 @@ def style_links(
|
||||
|
||||
|
||||
class StylesCache:
|
||||
"""Responsible for rendering CSS Styles and keeping a cached of rendered lines.
|
||||
"""Responsible for rendering CSS Styles and keeping a cache of rendered lines.
|
||||
|
||||
The render method applies border, outline, and padding set in the Styles object to widget content.
|
||||
|
||||
|
||||
@@ -355,7 +355,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def return_value(self) -> ReturnType | None:
|
||||
"""Get the return type."""
|
||||
"""ReturnType | None: The return type of the app."""
|
||||
return self._return_value
|
||||
|
||||
def animate(
|
||||
@@ -396,32 +396,17 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def debug(self) -> bool:
|
||||
"""Check if debug mode is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if debug mode is enabled.
|
||||
|
||||
"""
|
||||
"""bool: Is debug mode is enabled?"""
|
||||
return "debug" in self.features
|
||||
|
||||
@property
|
||||
def is_headless(self) -> bool:
|
||||
"""Check if the app is running in 'headless' mode.
|
||||
|
||||
Returns:
|
||||
bool: True if the app is in headless mode.
|
||||
|
||||
"""
|
||||
"""bool: Is the app running in 'headless' mode?"""
|
||||
return False if self._driver is None else self._driver.is_headless
|
||||
|
||||
@property
|
||||
def screen_stack(self) -> list[Screen]:
|
||||
"""Get a *copy* of the screen stack.
|
||||
|
||||
Returns:
|
||||
list[Screen]: List of screens.
|
||||
|
||||
"""
|
||||
"""list[Screen]: A *copy* of the screen stack."""
|
||||
return self._screen_stack.copy()
|
||||
|
||||
def exit(self, result: ReturnType | None = None) -> None:
|
||||
@@ -435,7 +420,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def focused(self) -> Widget | None:
|
||||
"""Get the widget that is focused on the currently active screen."""
|
||||
"""Widget | None: the widget that is focused on the currently active screen."""
|
||||
return self.screen.focused
|
||||
|
||||
@property
|
||||
@@ -514,13 +499,10 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def screen(self) -> Screen:
|
||||
"""Get the current screen.
|
||||
"""Screen: The current screen.
|
||||
|
||||
Raises:
|
||||
ScreenStackError: If there are no screens on the stack.
|
||||
|
||||
Returns:
|
||||
Screen: The currently active screen.
|
||||
"""
|
||||
try:
|
||||
return self._screen_stack[-1]
|
||||
@@ -529,11 +511,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def size(self) -> Size:
|
||||
"""Get the size of the terminal.
|
||||
|
||||
Returns:
|
||||
Size: Size of the terminal
|
||||
"""
|
||||
"""Size: The size of the terminal."""
|
||||
if self._driver is not None and self._driver._size is not None:
|
||||
width, height = self._driver._size
|
||||
else:
|
||||
@@ -542,6 +520,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def log(self) -> Logger:
|
||||
"""Logger: The logger object."""
|
||||
return self._logger
|
||||
|
||||
def _log(
|
||||
@@ -608,7 +587,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
Args:
|
||||
filename (str | None, optional): Filename of screenshot, or None to auto-generate. Defaults to None.
|
||||
path (str, optional): Path to directory. Defaults to "~/".
|
||||
path (str, optional): Path to directory. Defaults to current working directory.
|
||||
"""
|
||||
self.save_screenshot(filename, path)
|
||||
|
||||
@@ -824,9 +803,11 @@ class App(Generic[ReturnType], DOMNode):
|
||||
terminal_size=size,
|
||||
)
|
||||
finally:
|
||||
if auto_pilot_task is not None:
|
||||
await auto_pilot_task
|
||||
await app._shutdown()
|
||||
try:
|
||||
if auto_pilot_task is not None:
|
||||
await auto_pilot_task
|
||||
finally:
|
||||
await app._shutdown()
|
||||
|
||||
return app.return_value
|
||||
|
||||
@@ -1312,6 +1293,10 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
await self.animator.start()
|
||||
|
||||
except Exception:
|
||||
await self.animator.stop()
|
||||
raise
|
||||
|
||||
finally:
|
||||
await self._ready()
|
||||
await invoke_ready_callback()
|
||||
@@ -1324,10 +1309,11 @@ class App(Generic[ReturnType], DOMNode):
|
||||
pass
|
||||
finally:
|
||||
self._running = False
|
||||
for timer in list(self._timers):
|
||||
await timer.stop()
|
||||
|
||||
await self.animator.stop()
|
||||
try:
|
||||
await self.animator.stop()
|
||||
finally:
|
||||
for timer in list(self._timers):
|
||||
await timer.stop()
|
||||
|
||||
self._running = True
|
||||
try:
|
||||
@@ -1606,19 +1592,27 @@ class App(Generic[ReturnType], DOMNode):
|
||||
screen (Screen): Screen instance
|
||||
renderable (RenderableType): A Rich renderable.
|
||||
"""
|
||||
if screen is not self.screen or renderable is None:
|
||||
return
|
||||
if self._running and not self._closed and not self.is_headless:
|
||||
console = self.console
|
||||
self._begin_update()
|
||||
try:
|
||||
|
||||
try:
|
||||
if screen is not self.screen or renderable is None:
|
||||
return
|
||||
|
||||
if self._running and not self._closed and not self.is_headless:
|
||||
console = self.console
|
||||
self._begin_update()
|
||||
try:
|
||||
console.print(renderable)
|
||||
except Exception as error:
|
||||
self._handle_exception(error)
|
||||
finally:
|
||||
self._end_update()
|
||||
console.file.flush()
|
||||
try:
|
||||
console.print(renderable)
|
||||
except Exception as error:
|
||||
self._handle_exception(error)
|
||||
finally:
|
||||
self._end_update()
|
||||
console.file.flush()
|
||||
finally:
|
||||
self.post_display_hook()
|
||||
|
||||
def post_display_hook(self) -> None:
|
||||
"""Called immediately after a display is done. Used in tests."""
|
||||
|
||||
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
|
||||
"""Get the widget under the given coordinates.
|
||||
@@ -1652,7 +1646,9 @@ class App(Generic[ReturnType], DOMNode):
|
||||
(self, self._bindings),
|
||||
]
|
||||
else:
|
||||
namespace_bindings = [(node, node._bindings) for node in focused.ancestors]
|
||||
namespace_bindings = [
|
||||
(node, node._bindings) for node in focused.ancestors_with_self
|
||||
]
|
||||
return namespace_bindings
|
||||
|
||||
async def check_bindings(self, key: str, universal: bool = False) -> bool:
|
||||
@@ -1821,24 +1817,83 @@ class App(Generic[ReturnType], DOMNode):
|
||||
event.stop()
|
||||
await self.screen.post_message(event)
|
||||
|
||||
async def _on_remove(self, event: events.Remove) -> None:
|
||||
widget = event.widget
|
||||
parent = widget.parent
|
||||
def _detach_from_dom(self, widgets: list[Widget]) -> list[Widget]:
|
||||
"""Detach a list of widgets from the DOM.
|
||||
|
||||
remove_widgets = widget.walk_children(
|
||||
Widget, with_self=True, method="depth", reverse=True
|
||||
)
|
||||
Args:
|
||||
widgets (list[Widget]): The list of widgets to detach from the DOM.
|
||||
|
||||
if self.screen.focused in remove_widgets:
|
||||
self.screen._reset_focus(
|
||||
self.screen.focused,
|
||||
[to_remove for to_remove in remove_widgets if to_remove.can_focus],
|
||||
Returns:
|
||||
list[Widget]: The list of widgets that should be pruned.
|
||||
|
||||
Note:
|
||||
A side-effect of calling this function is that each parent of
|
||||
each affected widget will be made to forget about the affected
|
||||
child.
|
||||
"""
|
||||
|
||||
# We've been given a list of widgets to remove, but removing those
|
||||
# will also result in other (descendent) widgets being removed. So
|
||||
# to start with let's get a list of everything that's not going to
|
||||
# be in the DOM by the time we've finished. Note that, at this
|
||||
# point, it's entirely possible that there will be duplicates.
|
||||
everything_to_remove: list[Widget] = []
|
||||
for widget in widgets:
|
||||
everything_to_remove.extend(
|
||||
widget.walk_children(
|
||||
Widget, with_self=True, method="depth", reverse=True
|
||||
)
|
||||
)
|
||||
|
||||
await self._prune_node(widget)
|
||||
# Next up, let's quickly create a deduped collection of things to
|
||||
# remove and ensure that, if one of them is the focused widget,
|
||||
# focus gets moved to somewhere else.
|
||||
dedupe_to_remove = set(everything_to_remove)
|
||||
if self.screen.focused in dedupe_to_remove:
|
||||
self.screen._reset_focus(
|
||||
self.screen.focused,
|
||||
[to_remove for to_remove in dedupe_to_remove if to_remove.can_focus],
|
||||
)
|
||||
|
||||
if parent is not None:
|
||||
parent.refresh(layout=True)
|
||||
# Next, we go through the set of widgets we've been asked to remove
|
||||
# and try and find the minimal collection of widgets that will
|
||||
# result in everything else that should be removed, being removed.
|
||||
# In other words: find the smallest set of ancestors in the DOM that
|
||||
# will remove the widgets requested for removal, and also ensure
|
||||
# that all knock-on effects happen too.
|
||||
request_remove = set(widgets)
|
||||
pruned_remove = [
|
||||
widget for widget in widgets if request_remove.isdisjoint(widget.ancestors)
|
||||
]
|
||||
|
||||
# Now that we know that minimal set of widgets, we go through them
|
||||
# and get their parents to forget about them. This has the effect of
|
||||
# snipping each affected branch from the DOM.
|
||||
for widget in pruned_remove:
|
||||
if widget.parent is not None:
|
||||
widget.parent.children._remove(widget)
|
||||
|
||||
# Return the list of widgets that should end up being sent off in a
|
||||
# prune event.
|
||||
return pruned_remove
|
||||
|
||||
async def _on_prune(self, event: events.Prune) -> None:
|
||||
"""Handle a prune event.
|
||||
|
||||
Args:
|
||||
event (events.Prune): The prune event.
|
||||
"""
|
||||
|
||||
try:
|
||||
# Prune all the widgets.
|
||||
for widget in event.widgets:
|
||||
await self._prune_node(widget)
|
||||
finally:
|
||||
# Finally, flag that we're done.
|
||||
event.finished_flag.set()
|
||||
|
||||
# Flag that the layout needs refreshing.
|
||||
self.refresh(layout=True)
|
||||
|
||||
def _walk_children(self, root: Widget) -> Iterable[list[Widget]]:
|
||||
"""Walk children depth first, generating widgets and a list of their siblings.
|
||||
|
||||
23
src/textual/await_remove.py
Normal file
23
src/textual/await_remove.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Provides the type of an awaitable remove."""
|
||||
|
||||
from asyncio import Event
|
||||
from typing import Generator
|
||||
|
||||
|
||||
class AwaitRemove:
|
||||
"""An awaitable returned by App.remove and DOMQuery.remove."""
|
||||
|
||||
def __init__(self, finished_flag: Event) -> None:
|
||||
"""Initialise the instance of ``AwaitRemove``.
|
||||
|
||||
Args:
|
||||
finished_flag (asyncio.Event): The asyncio event to wait on.
|
||||
"""
|
||||
self.finished_flag = finished_flag
|
||||
|
||||
def __await__(self) -> Generator[None, None, None]:
|
||||
async def await_prune() -> None:
|
||||
"""Wait for the prune operation to finish."""
|
||||
await self.finished_flag.wait()
|
||||
|
||||
return await_prune().__await__()
|
||||
@@ -65,7 +65,7 @@ def get_box_model(
|
||||
else:
|
||||
# An explicit width
|
||||
styles_width = styles.width
|
||||
content_width = styles_width.resolve_dimension(
|
||||
content_width = styles_width.resolve(
|
||||
sizing_container - styles.margin.totals, viewport, width_fraction
|
||||
)
|
||||
if is_border_box and styles_width.excludes_border:
|
||||
@@ -73,14 +73,14 @@ def get_box_model(
|
||||
|
||||
if styles.min_width is not None:
|
||||
# Restrict to minimum width, if set
|
||||
min_width = styles.min_width.resolve_dimension(
|
||||
min_width = styles.min_width.resolve(
|
||||
content_container, viewport, width_fraction
|
||||
)
|
||||
content_width = max(content_width, min_width)
|
||||
|
||||
if styles.max_width is not None:
|
||||
# Restrict to maximum width, if set
|
||||
max_width = styles.max_width.resolve_dimension(
|
||||
max_width = styles.max_width.resolve(
|
||||
content_container, viewport, width_fraction
|
||||
)
|
||||
if is_border_box:
|
||||
@@ -100,7 +100,7 @@ def get_box_model(
|
||||
else:
|
||||
styles_height = styles.height
|
||||
# Explicit height set
|
||||
content_height = styles_height.resolve_dimension(
|
||||
content_height = styles_height.resolve(
|
||||
sizing_container - styles.margin.totals, viewport, height_fraction
|
||||
)
|
||||
if is_border_box and styles_height.excludes_border:
|
||||
@@ -108,14 +108,14 @@ def get_box_model(
|
||||
|
||||
if styles.min_height is not None:
|
||||
# Restrict to minimum height, if set
|
||||
min_height = styles.min_height.resolve_dimension(
|
||||
min_height = styles.min_height.resolve(
|
||||
content_container, viewport, height_fraction
|
||||
)
|
||||
content_height = max(content_height, min_height)
|
||||
|
||||
if styles.max_height is not None:
|
||||
# Restrict maximum height, if set
|
||||
max_height = styles.max_height.resolve_dimension(
|
||||
max_height = styles.max_height.resolve(
|
||||
content_container, viewport, height_fraction
|
||||
)
|
||||
content_height = min(content_height, max_height)
|
||||
|
||||
@@ -71,7 +71,7 @@ class ColorsApp(App):
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.call_later(self.update_view)
|
||||
self.call_after_refresh(self.update_view)
|
||||
|
||||
def update_view(self) -> None:
|
||||
content = self.query_one("Content", Content)
|
||||
|
||||
@@ -17,9 +17,13 @@ a method which evaluates the query, such as first() and last().
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast, Generic, TYPE_CHECKING, Iterator, TypeVar, overload
|
||||
import asyncio
|
||||
|
||||
import rich.repr
|
||||
|
||||
from .. import events
|
||||
from .._context import active_app
|
||||
from ..await_remove import AwaitRemove
|
||||
from .errors import DeclarationError, TokenError
|
||||
from .match import match
|
||||
from .model import SelectorSet
|
||||
@@ -346,11 +350,22 @@ class DOMQuery(Generic[QueryType]):
|
||||
node.toggle_class(*class_names)
|
||||
return self
|
||||
|
||||
def remove(self) -> DOMQuery[QueryType]:
|
||||
"""Remove matched nodes from the DOM"""
|
||||
for node in self:
|
||||
node.remove()
|
||||
return self
|
||||
def remove(self) -> AwaitRemove:
|
||||
"""Remove matched nodes from the DOM.
|
||||
|
||||
Returns:
|
||||
AwaitRemove: An awaitable object that waits for the widgets to be removed.
|
||||
"""
|
||||
prune_finished_event = asyncio.Event()
|
||||
app = active_app.get()
|
||||
app.post_message_no_wait(
|
||||
events.Prune(
|
||||
app,
|
||||
widgets=app._detach_from_dom(list(self)),
|
||||
finished_flag=prune_finished_event,
|
||||
)
|
||||
)
|
||||
return AwaitRemove(prune_finished_event)
|
||||
|
||||
def set_styles(
|
||||
self, css: str | None = None, **update_styles
|
||||
|
||||
@@ -268,7 +268,7 @@ class Scalar(NamedTuple):
|
||||
return scalar
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def resolve_dimension(
|
||||
def resolve(
|
||||
self, size: Size, viewport: Size, fraction_unit: Fraction | None = None
|
||||
) -> Fraction:
|
||||
"""Resolve scalar with units in to a dimensions.
|
||||
@@ -363,8 +363,8 @@ class ScalarOffset(NamedTuple):
|
||||
"""
|
||||
x, y = self
|
||||
return Offset(
|
||||
round(x.resolve_dimension(size, viewport)),
|
||||
round(y.resolve_dimension(size, viewport)),
|
||||
round(x.resolve(size, viewport)),
|
||||
round(y.resolve(size, viewport)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,25 +2,26 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .scalar import ScalarOffset
|
||||
from .scalar import ScalarOffset, Scalar
|
||||
from .._animator import Animation
|
||||
from .._animator import EasingFunction
|
||||
from .._types import CallbackType
|
||||
from ..geometry import Offset
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
from .styles import Styles
|
||||
from ..dom import DOMNode
|
||||
|
||||
from .styles import StylesBase
|
||||
|
||||
|
||||
class ScalarAnimation(Animation):
|
||||
def __init__(
|
||||
self,
|
||||
widget: Widget,
|
||||
styles: Styles,
|
||||
widget: DOMNode,
|
||||
styles: StylesBase,
|
||||
start_time: float,
|
||||
attribute: str,
|
||||
value: ScalarOffset,
|
||||
value: ScalarOffset | Scalar,
|
||||
duration: float | None,
|
||||
speed: float | None,
|
||||
easing: EasingFunction,
|
||||
@@ -40,8 +41,8 @@ class ScalarAnimation(Animation):
|
||||
size = widget.outer_size
|
||||
viewport = widget.app.size
|
||||
|
||||
self.start: Offset = getattr(styles, attribute).resolve(size, viewport)
|
||||
self.destination: Offset = value.resolve(size, viewport)
|
||||
self.start = getattr(styles, attribute).resolve(size, viewport)
|
||||
self.destination = value.resolve(size, viewport)
|
||||
|
||||
if speed is not None:
|
||||
distance = self.start.get_distance_to(self.destination)
|
||||
@@ -62,7 +63,7 @@ class ScalarAnimation(Animation):
|
||||
value = self.start.blend(self.destination, eased_factor)
|
||||
else:
|
||||
value = self.start + (self.destination - self.start) * eased_factor
|
||||
current = self.styles._rules.get(self.attribute)
|
||||
current = self.styles.get_rule(self.attribute)
|
||||
if current != value:
|
||||
setattr(self.styles, f"{self.attribute}", value)
|
||||
|
||||
|
||||
@@ -300,6 +300,44 @@ class StylesBase(ABC):
|
||||
link_hover_background = ColorProperty("transparent")
|
||||
link_hover_style = StyleFlagsProperty()
|
||||
|
||||
def __textual_animation__(
|
||||
self,
|
||||
attribute: str,
|
||||
start_value: object,
|
||||
value: object,
|
||||
start_time: float,
|
||||
duration: float | None,
|
||||
speed: float | None,
|
||||
easing: EasingFunction,
|
||||
on_complete: CallbackType | None = None,
|
||||
) -> ScalarAnimation | None:
|
||||
if self.node is None:
|
||||
return None
|
||||
|
||||
# Check we are animating a Scalar or Scalar offset
|
||||
if isinstance(start_value, (Scalar, ScalarOffset)):
|
||||
|
||||
# If destination is a number, we can convert that to a scalar
|
||||
if isinstance(value, (int, float)):
|
||||
value = Scalar(value, Unit.CELLS, Unit.CELLS)
|
||||
|
||||
# We can only animate to Scalar
|
||||
if not isinstance(value, (Scalar, ScalarOffset)):
|
||||
return None
|
||||
|
||||
return ScalarAnimation(
|
||||
self.node,
|
||||
self,
|
||||
start_time,
|
||||
attribute,
|
||||
value,
|
||||
duration=duration,
|
||||
speed=speed,
|
||||
easing=easing,
|
||||
on_complete=on_complete,
|
||||
)
|
||||
return None
|
||||
|
||||
def __eq__(self, styles: object) -> bool:
|
||||
"""Check that Styles contains the same rules."""
|
||||
if not isinstance(styles, StylesBase):
|
||||
@@ -556,10 +594,9 @@ class Styles(StylesBase):
|
||||
"""
|
||||
if value is None:
|
||||
return self._rules.pop(rule, None) is not None
|
||||
else:
|
||||
current = self._rules.get(rule)
|
||||
self._rules[rule] = value
|
||||
return current != value
|
||||
current = self._rules.get(rule)
|
||||
self._rules[rule] = value
|
||||
return current != value
|
||||
|
||||
def get_rule(self, rule: str, default: object = None) -> object:
|
||||
return self._rules.get(rule, default)
|
||||
@@ -628,30 +665,6 @@ class Styles(StylesBase):
|
||||
if self.important:
|
||||
yield "important", self.important
|
||||
|
||||
def __textual_animation__(
|
||||
self,
|
||||
attribute: str,
|
||||
value: Any,
|
||||
start_time: float,
|
||||
duration: float | None,
|
||||
speed: float | None,
|
||||
easing: EasingFunction,
|
||||
on_complete: CallbackType | None = None,
|
||||
) -> ScalarAnimation | None:
|
||||
if isinstance(value, ScalarOffset):
|
||||
return ScalarAnimation(
|
||||
self.node,
|
||||
self,
|
||||
start_time,
|
||||
attribute,
|
||||
value,
|
||||
duration=duration,
|
||||
speed=speed,
|
||||
easing=easing,
|
||||
on_complete=on_complete,
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_border_css_lines(
|
||||
self, rules: RulesMap, name: str
|
||||
) -> Iterable[tuple[str, str]]:
|
||||
@@ -936,7 +949,7 @@ class RenderStyles(StylesBase):
|
||||
def animate(
|
||||
self,
|
||||
attribute: str,
|
||||
value: float | Animatable,
|
||||
value: str | float | Animatable,
|
||||
*,
|
||||
final_value: object = ...,
|
||||
duration: float | None = None,
|
||||
|
||||
@@ -71,17 +71,12 @@ class StylesheetErrors:
|
||||
|
||||
if token.referenced_by:
|
||||
line_idx, col_idx = token.referenced_by.location
|
||||
line_no, col_no = line_idx + 1, col_idx + 1
|
||||
path_string = (
|
||||
f"{path.absolute() if path else filename}:{line_no}:{col_no}"
|
||||
)
|
||||
else:
|
||||
line_idx, col_idx = token.location
|
||||
line_no, col_no = line_idx + 1, col_idx + 1
|
||||
path_string = (
|
||||
f"{path.absolute() if path else filename}:{line_no}:{col_no}"
|
||||
)
|
||||
|
||||
line_no, col_no = line_idx + 1, col_idx + 1
|
||||
path_string = (
|
||||
f"{path.absolute() if path else filename}:{line_no}:{col_no}"
|
||||
)
|
||||
link_style = Style(
|
||||
link=f"file://{path.absolute()}",
|
||||
color="red",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import deque
|
||||
from inspect import getfile
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
@@ -34,6 +33,7 @@ from .css.styles import RenderStyles, Styles
|
||||
from .css.tokenize import IDENTIFIER
|
||||
from .message_pump import MessagePump
|
||||
from .timer import Timer
|
||||
from .walk import walk_breadth_first, walk_depth_first
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
@@ -45,6 +45,7 @@ from textual._typing import Literal, TypeAlias
|
||||
|
||||
_re_identifier = re.compile(IDENTIFIER)
|
||||
|
||||
|
||||
WalkMethod: TypeAlias = Literal["depth", "breadth"]
|
||||
|
||||
|
||||
@@ -179,11 +180,7 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def _node_bases(self) -> Iterator[Type[DOMNode]]:
|
||||
"""Get the DOMNode bases classes (including self.__class__)
|
||||
|
||||
Returns:
|
||||
Iterator[Type[DOMNode]]: An iterable of DOMNode classes.
|
||||
"""
|
||||
"""Iterator[Type[DOMNode]]: The DOMNode bases classes (including self.__class__)"""
|
||||
# Node bases are in reversed order so that the base class is lower priority
|
||||
return self._css_bases(self.__class__)
|
||||
|
||||
@@ -248,17 +245,16 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def parent(self) -> DOMNode | None:
|
||||
"""Get the parent node.
|
||||
|
||||
Returns:
|
||||
DOMNode | None: The node which is the direct parent of this node.
|
||||
"""
|
||||
|
||||
"""DOMNode | None: The parent node."""
|
||||
return cast("DOMNode | None", self._parent)
|
||||
|
||||
@property
|
||||
def screen(self) -> "Screen":
|
||||
"""Get the screen that this node is contained within. Note that this may not be the currently active screen within the app."""
|
||||
"""Screen: The screen that this node is contained within.
|
||||
|
||||
Note:
|
||||
This may not be the currently active screen within the app.
|
||||
"""
|
||||
# Get the node by looking up a chain of parents
|
||||
# Note that self.screen may not be the same as self.app.screen
|
||||
from .screen import Screen
|
||||
@@ -272,11 +268,7 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def id(self) -> str | None:
|
||||
"""The ID of this node, or None if the node has no ID.
|
||||
|
||||
Returns:
|
||||
(str | None): A Node ID or None.
|
||||
"""
|
||||
"""str | None: The ID of this node, or None if the node has no ID."""
|
||||
return self._id
|
||||
|
||||
@id.setter
|
||||
@@ -301,11 +293,12 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""str | None: The name of the node."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def css_identifier(self) -> str:
|
||||
"""A CSS selector that identifies this DOM node."""
|
||||
"""str: A CSS selector that identifies this DOM node."""
|
||||
tokens = [self.__class__.__name__]
|
||||
if self.id is not None:
|
||||
tokens.append(f"#{self.id}")
|
||||
@@ -313,7 +306,7 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def css_identifier_styled(self) -> Text:
|
||||
"""A stylized CSS identifier."""
|
||||
"""Text: A stylized CSS identifier."""
|
||||
tokens = Text.styled(self.__class__.__name__)
|
||||
if self.id is not None:
|
||||
tokens.append(f"#{self.id}", style="bold")
|
||||
@@ -326,27 +319,18 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def classes(self) -> frozenset[str]:
|
||||
"""A frozenset of the current classes set on the widget.
|
||||
|
||||
Returns:
|
||||
frozenset[str]: Set of class names.
|
||||
|
||||
"""
|
||||
"""frozenset[str]: A frozenset of the current classes set on the widget."""
|
||||
return frozenset(self._classes)
|
||||
|
||||
@property
|
||||
def pseudo_classes(self) -> frozenset[str]:
|
||||
"""Get a set of all pseudo classes"""
|
||||
"""frozenset[str]: A set of all pseudo classes"""
|
||||
pseudo_classes = frozenset({*self.get_pseudo_classes()})
|
||||
return pseudo_classes
|
||||
|
||||
@property
|
||||
def css_path_nodes(self) -> list[DOMNode]:
|
||||
"""A list of nodes from the root to this node, forming a "path".
|
||||
|
||||
Returns:
|
||||
list[DOMNode]: List of Nodes, starting with the root and ending with this node.
|
||||
"""
|
||||
"""list[DOMNode] A list of nodes from the root to this node, forming a "path"."""
|
||||
result: list[DOMNode] = [self]
|
||||
append = result.append
|
||||
|
||||
@@ -488,7 +472,7 @@ class DOMNode(MessagePump):
|
||||
Style: Rich Style object.
|
||||
"""
|
||||
return Style.combine(
|
||||
node.styles.text_style for node in reversed(self.ancestors)
|
||||
node.styles.text_style for node in reversed(self.ancestors_with_self)
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -497,7 +481,7 @@ class DOMNode(MessagePump):
|
||||
background = WHITE
|
||||
color = BLACK
|
||||
style = Style()
|
||||
for node in reversed(self.ancestors):
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
styles = node.styles
|
||||
if styles.has_rule("background"):
|
||||
background += styles.background
|
||||
@@ -520,7 +504,7 @@ class DOMNode(MessagePump):
|
||||
|
||||
"""
|
||||
base_background = background = BLACK
|
||||
for node in reversed(self.ancestors):
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
styles = node.styles
|
||||
if styles.has_rule("background"):
|
||||
base_background = background
|
||||
@@ -536,7 +520,7 @@ class DOMNode(MessagePump):
|
||||
"""
|
||||
base_background = background = WHITE
|
||||
base_color = color = BLACK
|
||||
for node in reversed(self.ancestors):
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
styles = node.styles
|
||||
if styles.has_rule("background"):
|
||||
base_background = background
|
||||
@@ -551,8 +535,11 @@ class DOMNode(MessagePump):
|
||||
return (base_background, base_color, background, color)
|
||||
|
||||
@property
|
||||
def ancestors(self) -> list[DOMNode]:
|
||||
"""Get a list of Nodes by tracing ancestors all the way back to App."""
|
||||
def ancestors_with_self(self) -> list[DOMNode]:
|
||||
"""list[DOMNode]: A list of Nodes by tracing a path all the way back to App.
|
||||
|
||||
Note: This is inclusive of ``self``.
|
||||
"""
|
||||
nodes: list[MessagePump | None] = []
|
||||
add_node = nodes.append
|
||||
node: MessagePump | None = self
|
||||
@@ -561,6 +548,11 @@ class DOMNode(MessagePump):
|
||||
node = node._parent
|
||||
return cast("list[DOMNode]", nodes)
|
||||
|
||||
@property
|
||||
def ancestors(self) -> list[DOMNode]:
|
||||
"""list[DOMNode]: A list of ancestor nodes Nodes by tracing ancestors all the way back to App."""
|
||||
return self.ancestors_with_self[1:]
|
||||
|
||||
@property
|
||||
def displayed_children(self) -> list[Widget]:
|
||||
"""The children which don't have display: none set.
|
||||
@@ -609,7 +601,7 @@ class DOMNode(MessagePump):
|
||||
node._attach(self)
|
||||
_append(node)
|
||||
|
||||
WalkType = TypeVar("WalkType")
|
||||
WalkType = TypeVar("WalkType", bound="DOMNode")
|
||||
|
||||
@overload
|
||||
def walk_children(
|
||||
@@ -654,43 +646,12 @@ class DOMNode(MessagePump):
|
||||
|
||||
"""
|
||||
|
||||
def walk_depth_first() -> Iterable[DOMNode]:
|
||||
"""Walk the tree depth first (parents first)."""
|
||||
stack: list[Iterator[DOMNode]] = [iter(self.children)]
|
||||
pop = stack.pop
|
||||
push = stack.append
|
||||
check_type = filter_type or DOMNode
|
||||
|
||||
if with_self and isinstance(self, check_type):
|
||||
yield self
|
||||
while stack:
|
||||
node = next(stack[-1], None)
|
||||
if node is None:
|
||||
pop()
|
||||
else:
|
||||
if isinstance(node, check_type):
|
||||
yield node
|
||||
if node.children:
|
||||
push(iter(node.children))
|
||||
|
||||
def walk_breadth_first() -> Iterable[DOMNode]:
|
||||
"""Walk the tree breadth first (children first)."""
|
||||
queue: deque[DOMNode] = deque()
|
||||
popleft = queue.popleft
|
||||
extend = queue.extend
|
||||
check_type = filter_type or DOMNode
|
||||
|
||||
if with_self and isinstance(self, check_type):
|
||||
yield self
|
||||
extend(self.children)
|
||||
while queue:
|
||||
node = popleft()
|
||||
if isinstance(node, check_type):
|
||||
yield node
|
||||
extend(node.children)
|
||||
check_type = filter_type or DOMNode
|
||||
|
||||
node_generator = (
|
||||
walk_depth_first() if method == "depth" else walk_breadth_first()
|
||||
walk_depth_first(self, check_type, with_root=with_self)
|
||||
if method == "depth"
|
||||
else walk_breadth_first(self, check_type, with_root=with_self)
|
||||
)
|
||||
|
||||
# We want a snapshot of the DOM at this point So that it doesn't
|
||||
@@ -698,7 +659,7 @@ class DOMNode(MessagePump):
|
||||
nodes = list(node_generator)
|
||||
if reverse:
|
||||
nodes.reverse()
|
||||
return nodes
|
||||
return cast("list[DOMNode]", nodes)
|
||||
|
||||
def get_child(self, id: str) -> DOMNode:
|
||||
"""Return the first child (immediate descendent) of this node with the given ID.
|
||||
|
||||
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
|
||||
from .timer import Timer as TimerClass
|
||||
from .timer import TimerCallback
|
||||
from .widget import Widget
|
||||
import asyncio
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -126,12 +127,26 @@ class Unmount(Mount, bubble=False, verbose=False):
|
||||
"""Sent when a widget is unmounted and may not longer receive messages."""
|
||||
|
||||
|
||||
class Remove(Event, bubble=False):
|
||||
"""Sent to a widget to ask it to remove itself from the DOM."""
|
||||
class Prune(Event, bubble=False):
|
||||
"""Sent to the app to ask it to prune one or more widgets from the DOM.
|
||||
|
||||
def __init__(self, sender: MessageTarget, widget: Widget) -> None:
|
||||
self.widget = widget
|
||||
Attributes:
|
||||
widgets (list[Widgets]): The list of widgets to prune.
|
||||
finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, sender: MessageTarget, widgets: list[Widget], finished_flag: asyncio.Event
|
||||
) -> None:
|
||||
"""Initialise the event.
|
||||
|
||||
Args:
|
||||
widgets (list[Widgets]): The list of widgets to prune.
|
||||
finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done.
|
||||
"""
|
||||
super().__init__(sender)
|
||||
self.finished_flag = finished_flag
|
||||
self.widgets = widgets
|
||||
|
||||
|
||||
class Show(Event, bubble=False):
|
||||
|
||||
@@ -251,7 +251,7 @@ class MessagePump(metaclass=MessagePumpMeta):
|
||||
self._timers.add(timer)
|
||||
return timer
|
||||
|
||||
def call_later(self, callback: Callable, *args, **kwargs) -> None:
|
||||
def call_after_refresh(self, callback: Callable, *args, **kwargs) -> None:
|
||||
"""Schedule a callback to run after all messages are processed and the screen
|
||||
has been refreshed. Positional and keyword arguments are passed to the callable.
|
||||
|
||||
@@ -263,6 +263,16 @@ class MessagePump(metaclass=MessagePumpMeta):
|
||||
message = messages.InvokeLater(self, partial(callback, *args, **kwargs))
|
||||
self.post_message_no_wait(message)
|
||||
|
||||
def call_later(self, callback: Callable, *args, **kwargs) -> None:
|
||||
"""Schedule a callback to run after all messages are processed in this object.
|
||||
Positional and keywords arguments are passed to the callable.
|
||||
|
||||
Args:
|
||||
callback (Callable): Callable to call next.
|
||||
"""
|
||||
message = events.Callback(self, callback=partial(callback, *args, **kwargs))
|
||||
self.post_message_no_wait(message)
|
||||
|
||||
def _on_invoke_later(self, message: messages.InvokeLater) -> None:
|
||||
# Forward InvokeLater message to the Screen
|
||||
self.app.screen._invoke_later(message.callback)
|
||||
|
||||
@@ -21,11 +21,7 @@ class Pilot:
|
||||
|
||||
@property
|
||||
def app(self) -> App:
|
||||
"""Get a reference to the application.
|
||||
|
||||
Returns:
|
||||
App: The App instance.
|
||||
"""
|
||||
"""App: A reference to the application."""
|
||||
return self._app
|
||||
|
||||
async def press(self, *keys: str) -> None:
|
||||
@@ -44,8 +40,13 @@ class Pilot:
|
||||
Args:
|
||||
delay (float, optional): Seconds to pause. Defaults to 50ms.
|
||||
"""
|
||||
# These sleep zeros, are to force asyncio to give up a time-slice,
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def wait_for_animation(self) -> None:
|
||||
"""Wait for any animation to complete."""
|
||||
await self._app.animator.wait_for_idle()
|
||||
|
||||
async def exit(self, result: object) -> None:
|
||||
"""Exit the app with the given result.
|
||||
|
||||
|
||||
@@ -2,7 +2,16 @@ from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from inspect import isawaitable
|
||||
from typing import TYPE_CHECKING, Any, Callable, Generic, Type, TypeVar, Union
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Generic,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
from . import events
|
||||
from ._callback import count_parameters, invoke
|
||||
@@ -146,6 +155,7 @@ class Reactive(Generic[ReactiveType]):
|
||||
setattr(owner, f"_default_{name}", default)
|
||||
|
||||
def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType:
|
||||
_rich_traceback_omit = True
|
||||
value: _NotSet | ReactiveType = getattr(obj, self.internal_name, _NOT_SET)
|
||||
if isinstance(value, _NotSet):
|
||||
# No value present, we need to set the default
|
||||
@@ -155,11 +165,12 @@ class Reactive(Generic[ReactiveType]):
|
||||
# Set and return the value
|
||||
setattr(obj, self.internal_name, default_value)
|
||||
if self._init:
|
||||
self._check_watchers(obj, self.name, default_value, first_set=True)
|
||||
self._check_watchers(obj, self.name, default_value)
|
||||
return default_value
|
||||
return value
|
||||
|
||||
def __set__(self, obj: Reactable, value: ReactiveType) -> None:
|
||||
_rich_traceback_omit = True
|
||||
name = self.name
|
||||
current_value = getattr(obj, name)
|
||||
# Check for validate function
|
||||
@@ -176,15 +187,13 @@ class Reactive(Generic[ReactiveType]):
|
||||
# Store the internal value
|
||||
setattr(obj, self.internal_name, value)
|
||||
# Check all watchers
|
||||
self._check_watchers(obj, name, current_value, first_set=first_set)
|
||||
self._check_watchers(obj, name, current_value)
|
||||
# Refresh according to descriptor flags
|
||||
if self._layout or self._repaint:
|
||||
obj.refresh(repaint=self._repaint, layout=self._layout)
|
||||
|
||||
@classmethod
|
||||
def _check_watchers(
|
||||
cls, obj: Reactable, name: str, old_value: Any, first_set: bool = False
|
||||
) -> None:
|
||||
def _check_watchers(cls, obj: Reactable, name: str, old_value: Any):
|
||||
"""Check watchers, and call watch methods / computes
|
||||
|
||||
Args:
|
||||
@@ -193,60 +202,68 @@ class Reactive(Generic[ReactiveType]):
|
||||
old_value (Any): The old (previous) value of the attribute.
|
||||
first_set (bool, optional): True if this is the first time setting the value. Defaults to False.
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
# Get the current value.
|
||||
internal_name = f"_reactive_{name}"
|
||||
value = getattr(obj, internal_name)
|
||||
|
||||
async def update_watcher(
|
||||
obj: Reactable, watch_function: Callable, old_value: Any, value: Any
|
||||
) -> None:
|
||||
"""Call watch function, and run compute.
|
||||
async def await_watcher(awaitable: Awaitable) -> None:
|
||||
"""Coroutine to await an awaitable returned from a watcher"""
|
||||
_rich_traceback_omit = True
|
||||
await awaitable
|
||||
# Watcher may have changed the state, so run compute again
|
||||
obj.post_message_no_wait(
|
||||
events.Callback(sender=obj, callback=partial(Reactive._compute, obj))
|
||||
)
|
||||
|
||||
def invoke_watcher(
|
||||
watch_function: Callable, old_value: object, value: object
|
||||
) -> bool:
|
||||
"""Invoke a watch function.
|
||||
|
||||
Args:
|
||||
obj (Reactable): Reactable object.
|
||||
watch_function (Callable): Watch method.
|
||||
old_value (Any): Old value.
|
||||
value (Any): new value.
|
||||
watch_function (Callable): A watch function, which may be sync or async.
|
||||
old_value (object): The old value of the attribute.
|
||||
value (object): The new value of the attribute.
|
||||
|
||||
Returns:
|
||||
bool: True if the watcher was run, or False if it was posted.
|
||||
"""
|
||||
_rich_traceback_guard = True
|
||||
# Call watch with one or two parameters
|
||||
_rich_traceback_omit = True
|
||||
if count_parameters(watch_function) == 2:
|
||||
watch_result = watch_function(old_value, value)
|
||||
else:
|
||||
watch_result = watch_function(value)
|
||||
# Optionally await result
|
||||
if isawaitable(watch_result):
|
||||
await watch_result
|
||||
# Run computes
|
||||
await Reactive._compute(obj)
|
||||
# Result is awaitable, so we need to await it within an async context
|
||||
obj.post_message_no_wait(
|
||||
events.Callback(
|
||||
sender=obj, callback=partial(await_watcher, watch_result)
|
||||
)
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
# Check for watch method
|
||||
# Compute is only required if a watcher runs immediately, not if they were posted.
|
||||
require_compute = False
|
||||
watch_function = getattr(obj, f"watch_{name}", None)
|
||||
if callable(watch_function):
|
||||
# Post a callback message, so we can call the watch method in an orderly async manner
|
||||
obj.post_message_no_wait(
|
||||
events.Callback(
|
||||
sender=obj,
|
||||
callback=partial(
|
||||
update_watcher, obj, watch_function, old_value, value
|
||||
),
|
||||
)
|
||||
require_compute = require_compute or invoke_watcher(
|
||||
watch_function, old_value, value
|
||||
)
|
||||
|
||||
# Check for watchers set via `watch`
|
||||
watchers: list[Callable] = getattr(obj, "__watchers", {}).get(name, [])
|
||||
for watcher in watchers:
|
||||
obj.post_message_no_wait(
|
||||
events.Callback(
|
||||
sender=obj,
|
||||
callback=partial(update_watcher, obj, watcher, old_value, value),
|
||||
)
|
||||
require_compute = require_compute or invoke_watcher(
|
||||
watcher, old_value, value
|
||||
)
|
||||
|
||||
# Run computes
|
||||
obj.post_message_no_wait(
|
||||
events.Callback(sender=obj, callback=partial(Reactive._compute, obj))
|
||||
)
|
||||
if require_compute:
|
||||
# Run computes
|
||||
obj.post_message_no_wait(
|
||||
events.Callback(sender=obj, callback=partial(Reactive._compute, obj))
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _compute(cls, obj: Reactable) -> None:
|
||||
@@ -301,10 +318,13 @@ class var(Reactive[ReactiveType]):
|
||||
|
||||
Args:
|
||||
default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default.
|
||||
init (bool, optional): Call watchers on initialize (post mount). Defaults to True.
|
||||
"""
|
||||
|
||||
def __init__(self, default: ReactiveType | Callable[[], ReactiveType]) -> None:
|
||||
super().__init__(default, layout=False, repaint=False, init=True)
|
||||
def __init__(
|
||||
self, default: ReactiveType | Callable[[], ReactiveType], init: bool = True
|
||||
) -> None:
|
||||
super().__init__(default, layout=False, repaint=False, init=init)
|
||||
|
||||
|
||||
def watch(
|
||||
|
||||
@@ -333,11 +333,11 @@ class Screen(Widget):
|
||||
self._compositor.update_widgets(self._dirty_widgets)
|
||||
self.app._display(self, self._compositor.render())
|
||||
self._dirty_widgets.clear()
|
||||
|
||||
self.update_timer.pause()
|
||||
if self._callbacks:
|
||||
self.post_message_no_wait(events.InvokeCallbacks(self))
|
||||
|
||||
self.update_timer.pause()
|
||||
|
||||
async def _on_invoke_callbacks(self, event: events.InvokeCallbacks) -> None:
|
||||
"""Handle PostScreenUpdate events, which are sent after the screen is updated"""
|
||||
await self._invoke_and_clear_callbacks()
|
||||
@@ -346,6 +346,8 @@ class Screen(Widget):
|
||||
"""If there are scheduled callbacks to run, call them and clear
|
||||
the callback queue."""
|
||||
if self._callbacks:
|
||||
display_update = self._compositor.render()
|
||||
self.app._display(self, display_update)
|
||||
callbacks = self._callbacks[:]
|
||||
self._callbacks.clear()
|
||||
for callback in callbacks:
|
||||
@@ -402,8 +404,7 @@ class Screen(Widget):
|
||||
self.app._handle_exception(error)
|
||||
return
|
||||
display_update = self._compositor.render(full=full)
|
||||
if display_update is not None:
|
||||
self.app._display(self, display_update)
|
||||
self.app._display(self, display_update)
|
||||
|
||||
async def _on_update(self, message: messages.Update) -> None:
|
||||
message.stop()
|
||||
|
||||
126
src/textual/walk.py
Normal file
126
src/textual/walk.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import Iterable, Iterator, TypeVar, overload, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .dom import DOMNode
|
||||
|
||||
WalkType = TypeVar("WalkType", bound=DOMNode)
|
||||
|
||||
|
||||
@overload
|
||||
def walk_depth_first(
|
||||
root: DOMNode,
|
||||
*,
|
||||
with_root: bool = True,
|
||||
) -> Iterable[DOMNode]:
|
||||
...
|
||||
|
||||
|
||||
@overload
|
||||
def walk_depth_first(
|
||||
root: WalkType,
|
||||
filter_type: type[WalkType],
|
||||
*,
|
||||
with_root: bool = True,
|
||||
) -> Iterable[WalkType]:
|
||||
...
|
||||
|
||||
|
||||
def walk_depth_first(
|
||||
root: DOMNode,
|
||||
filter_type: type[WalkType] | None = None,
|
||||
*,
|
||||
with_root: bool = True,
|
||||
) -> Iterable[DOMNode] | Iterable[WalkType]:
|
||||
"""Walk the tree depth first (parents first).
|
||||
|
||||
!!! note
|
||||
|
||||
Avoid changing the DOM (mounting, removing etc.) while iterating with this function.
|
||||
Consider [walk_children][textual.dom.DOMNode.walk_children] which doesn't have this limitation.
|
||||
|
||||
Args:
|
||||
root (DOMNode): The root note (starting point).
|
||||
filter_type (type[WalkType] | None, optional): Optional DOMNode subclass to filter by, or ``None`` for no filter.
|
||||
Defaults to None.
|
||||
with_root (bool, optional): Include the root in the walk. Defaults to True.
|
||||
|
||||
Returns:
|
||||
Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in ``filter_type``.
|
||||
|
||||
"""
|
||||
stack: list[Iterator[DOMNode]] = [iter(root.children)]
|
||||
pop = stack.pop
|
||||
push = stack.append
|
||||
check_type = filter_type or DOMNode
|
||||
|
||||
if with_root and isinstance(root, check_type):
|
||||
yield root
|
||||
while stack:
|
||||
node = next(stack[-1], None)
|
||||
if node is None:
|
||||
pop()
|
||||
else:
|
||||
if isinstance(node, check_type):
|
||||
yield node
|
||||
if node.children:
|
||||
push(iter(node.children))
|
||||
|
||||
|
||||
@overload
|
||||
def walk_breadth_first(
|
||||
root: DOMNode,
|
||||
*,
|
||||
with_root: bool = True,
|
||||
) -> Iterable[DOMNode]:
|
||||
...
|
||||
|
||||
|
||||
@overload
|
||||
def walk_breadth_first(
|
||||
root: WalkType,
|
||||
filter_type: type[WalkType],
|
||||
*,
|
||||
with_root: bool = True,
|
||||
) -> Iterable[WalkType]:
|
||||
...
|
||||
|
||||
|
||||
def walk_breadth_first(
|
||||
root: DOMNode,
|
||||
filter_type: type[WalkType] | None = None,
|
||||
*,
|
||||
with_root: bool = True,
|
||||
) -> Iterable[DOMNode] | Iterable[WalkType]:
|
||||
"""Walk the tree breadth first (children first).
|
||||
|
||||
!!! note
|
||||
|
||||
Avoid changing the DOM (mounting, removing etc.) while iterating with this function.
|
||||
Consider [walk_children][textual.dom.DOMNode.walk_children] which doesn't have this limitation.
|
||||
|
||||
Args:
|
||||
root (DOMNode): The root note (starting point).
|
||||
filter_type (type[WalkType] | None, optional): Optional DOMNode subclass to filter by, or ``None`` for no filter.
|
||||
Defaults to None.
|
||||
with_root (bool, optional): Include the root in the walk. Defaults to True.
|
||||
|
||||
Returns:
|
||||
Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in ``filter_type``.
|
||||
|
||||
"""
|
||||
queue: deque[DOMNode] = deque()
|
||||
popleft = queue.popleft
|
||||
extend = queue.extend
|
||||
check_type = filter_type or DOMNode
|
||||
|
||||
if with_root and isinstance(root, check_type):
|
||||
yield root
|
||||
extend(root.children)
|
||||
while queue:
|
||||
node = popleft()
|
||||
if isinstance(node, check_type):
|
||||
yield node
|
||||
extend(node.children)
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Lock, wait, create_task
|
||||
from asyncio import Lock, wait, create_task, Event as AsyncEvent
|
||||
from fractions import Fraction
|
||||
from itertools import islice
|
||||
from operator import attrgetter
|
||||
@@ -34,6 +34,7 @@ from . import errors, events, messages
|
||||
from ._animator import BoundAnimator, DEFAULT_EASING, Animatable, EasingFunction
|
||||
from ._arrange import DockArrangeResult, arrange
|
||||
from ._context import active_app
|
||||
from ._easing import DEFAULT_SCROLL_EASING
|
||||
from ._layout import Layout
|
||||
from ._segment_tools import align_lines
|
||||
from ._styles_cache import StylesCache
|
||||
@@ -48,6 +49,7 @@ from .message import Message
|
||||
from .messages import CallbackType
|
||||
from .reactive import Reactive
|
||||
from .render import measure
|
||||
from .await_remove import AwaitRemove
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App, ComposeResult
|
||||
@@ -431,7 +433,7 @@ class Widget(DOMNode):
|
||||
# children. We should be able to go looking for the widget's
|
||||
# location amongst its parent's children.
|
||||
try:
|
||||
return spot.parent, spot.parent.children.index(spot)
|
||||
return cast("Widget", spot.parent), spot.parent.children.index(spot)
|
||||
except ValueError:
|
||||
raise MountError(f"{spot!r} is not a child of {self!r}") from None
|
||||
|
||||
@@ -478,6 +480,69 @@ class Widget(DOMNode):
|
||||
self.app._register(parent, *widgets, before=before, after=after)
|
||||
)
|
||||
|
||||
def move_child(
|
||||
self,
|
||||
child: int | Widget,
|
||||
before: int | Widget | None = None,
|
||||
after: int | Widget | None = None,
|
||||
) -> None:
|
||||
"""Move a child widget within its parent's list of children.
|
||||
|
||||
Args:
|
||||
child (int | Widget): The child widget to move.
|
||||
before: (int | Widget, optional): Optional location to move before.
|
||||
after: (int | Widget, optional): Optional location to move after.
|
||||
|
||||
Raises:
|
||||
WidgetError: If there is a problem with the child or target.
|
||||
|
||||
Note:
|
||||
Only one of ``before`` or ``after`` can be provided. If neither
|
||||
or both are provided a ``WidgetError`` will be raised.
|
||||
"""
|
||||
|
||||
# One or the other of before or after are required. Can't do
|
||||
# neither, can't do both.
|
||||
if before is None and after is None:
|
||||
raise WidgetError("One of `before` or `after` is required.")
|
||||
elif before is not None and after is not None:
|
||||
raise WidgetError("Only one of `before`or `after` can be handled.")
|
||||
|
||||
def _to_widget(child: int | Widget, called: str) -> Widget:
|
||||
"""Ensure a given child reference is a Widget."""
|
||||
if isinstance(child, int):
|
||||
try:
|
||||
child = self.children[child]
|
||||
except IndexError:
|
||||
raise WidgetError(
|
||||
f"An index of {child} for the child to {called} is out of bounds"
|
||||
) from None
|
||||
else:
|
||||
# We got an actual widget, so let's be sure it really is one of
|
||||
# our children.
|
||||
try:
|
||||
_ = self.children.index(child)
|
||||
except ValueError:
|
||||
raise WidgetError(f"{child!r} is not a child of {self!r}") from None
|
||||
return child
|
||||
|
||||
# Ensure the child and target are widgets.
|
||||
child = _to_widget(child, "move")
|
||||
target = _to_widget(before if after is None else after, "move towards")
|
||||
|
||||
# At this point we should know what we're moving, and it should be a
|
||||
# child; where we're moving it to, which should be within the child
|
||||
# list; and how we're supposed to move it. All that's left is doing
|
||||
# the right thing.
|
||||
self.children._remove(child)
|
||||
if before is not None:
|
||||
self.children._insert(self.children.index(target), child)
|
||||
else:
|
||||
self.children._insert(self.children.index(target) + 1, child)
|
||||
|
||||
# Request a refresh.
|
||||
self.refresh(layout=True)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Called by Textual to create child widgets.
|
||||
|
||||
@@ -1054,7 +1119,7 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
tuple[str, ...]: Tuple of layer names.
|
||||
"""
|
||||
for node in self.ancestors:
|
||||
for node in self.ancestors_with_self:
|
||||
if not isinstance(node, Widget):
|
||||
break
|
||||
if node.styles.has_rule("layers"):
|
||||
@@ -1136,6 +1201,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll to a given (absolute) coordinate, optionally animating.
|
||||
|
||||
@@ -1145,6 +1211,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate to new scroll position. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if the scroll position changed, otherwise False.
|
||||
@@ -1154,6 +1222,10 @@ class Widget(DOMNode):
|
||||
# TODO: configure animation speed
|
||||
if duration is None and speed is None:
|
||||
speed = 50
|
||||
|
||||
if easing is None:
|
||||
easing = DEFAULT_SCROLL_EASING
|
||||
|
||||
if x is not None:
|
||||
self.scroll_target_x = x
|
||||
if x != self.scroll_x:
|
||||
@@ -1162,7 +1234,7 @@ class Widget(DOMNode):
|
||||
self.scroll_target_x,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing="out_cubic",
|
||||
easing=easing,
|
||||
)
|
||||
scrolled_x = True
|
||||
if y is not None:
|
||||
@@ -1173,7 +1245,7 @@ class Widget(DOMNode):
|
||||
self.scroll_target_y,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing="out_cubic",
|
||||
easing=easing,
|
||||
)
|
||||
scrolled_y = True
|
||||
|
||||
@@ -1197,6 +1269,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll relative to current position.
|
||||
|
||||
@@ -1206,6 +1279,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate to new scroll position. Defaults to False.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if the scroll position changed, otherwise False.
|
||||
@@ -1216,6 +1291,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_home(
|
||||
@@ -1224,6 +1300,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll to home position.
|
||||
|
||||
@@ -1231,13 +1308,17 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
"""
|
||||
if speed is None and duration is None:
|
||||
duration = 1.0
|
||||
return self.scroll_to(0, 0, animate=animate, speed=speed, duration=duration)
|
||||
return self.scroll_to(
|
||||
0, 0, animate=animate, speed=speed, duration=duration, easing=easing
|
||||
)
|
||||
|
||||
def scroll_end(
|
||||
self,
|
||||
@@ -1245,6 +1326,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll to the end of the container.
|
||||
|
||||
@@ -1252,6 +1334,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
@@ -1260,7 +1344,12 @@ class Widget(DOMNode):
|
||||
if speed is None and duration is None:
|
||||
duration = 1.0
|
||||
return self.scroll_to(
|
||||
0, self.max_scroll_y, animate=animate, speed=speed, duration=duration
|
||||
0,
|
||||
self.max_scroll_y,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_left(
|
||||
@@ -1269,6 +1358,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one cell left.
|
||||
|
||||
@@ -1276,13 +1366,19 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
|
||||
"""
|
||||
return self.scroll_to(
|
||||
x=self.scroll_target_x - 1, animate=animate, speed=speed, duration=duration
|
||||
x=self.scroll_target_x - 1,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_right(
|
||||
@@ -1291,6 +1387,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll on cell right.
|
||||
|
||||
@@ -1298,13 +1395,19 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
|
||||
"""
|
||||
return self.scroll_to(
|
||||
x=self.scroll_target_x + 1, animate=animate, speed=speed, duration=duration
|
||||
x=self.scroll_target_x + 1,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_down(
|
||||
@@ -1313,6 +1416,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one line down.
|
||||
|
||||
@@ -1320,13 +1424,19 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
|
||||
"""
|
||||
return self.scroll_to(
|
||||
y=self.scroll_target_y + 1, animate=animate, speed=speed, duration=duration
|
||||
y=self.scroll_target_y + 1,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_up(
|
||||
@@ -1335,6 +1445,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one line up.
|
||||
|
||||
@@ -1342,13 +1453,19 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
|
||||
"""
|
||||
return self.scroll_to(
|
||||
y=self.scroll_target_y - 1, animate=animate, speed=speed, duration=duration
|
||||
y=self.scroll_target_y - 1,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_page_up(
|
||||
@@ -1357,6 +1474,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one page up.
|
||||
|
||||
@@ -1364,6 +1482,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
@@ -1374,6 +1494,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_page_down(
|
||||
@@ -1382,6 +1503,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one page down.
|
||||
|
||||
@@ -1389,6 +1511,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
@@ -1399,6 +1523,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_page_left(
|
||||
@@ -1407,6 +1532,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one page left.
|
||||
|
||||
@@ -1414,6 +1540,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
@@ -1426,6 +1554,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_page_right(
|
||||
@@ -1434,6 +1563,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one page right.
|
||||
|
||||
@@ -1441,6 +1571,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
@@ -1453,6 +1585,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_to_widget(
|
||||
@@ -1462,6 +1595,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
top: bool = False,
|
||||
) -> bool:
|
||||
"""Scroll scrolling to bring a widget in to view.
|
||||
@@ -1471,6 +1605,9 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): True to animate, or False to jump. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
top (bool, optional): Scroll widget to top of container. Defaults to False.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling has occurred in any descendant, otherwise False.
|
||||
@@ -1489,6 +1626,7 @@ class Widget(DOMNode):
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
top=top,
|
||||
easing=easing,
|
||||
)
|
||||
if scroll_offset:
|
||||
scrolled = True
|
||||
@@ -1515,6 +1653,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
top: bool = False,
|
||||
) -> Offset:
|
||||
"""Scrolls a given region in to view, if required.
|
||||
@@ -1528,6 +1667,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): True to animate, or False to jump. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
top (bool, optional): Scroll region to top of container. Defaults to False.
|
||||
|
||||
Returns:
|
||||
@@ -1555,6 +1696,7 @@ class Widget(DOMNode):
|
||||
animate=animate if (abs(delta_y) > 1 or delta_x) else False,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
return delta
|
||||
|
||||
@@ -1565,6 +1707,7 @@ class Widget(DOMNode):
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
top: bool = False,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> None:
|
||||
"""Scroll the container to make this widget visible.
|
||||
|
||||
@@ -1573,16 +1716,19 @@ class Widget(DOMNode):
|
||||
speed (float | None, optional): _description_. Defaults to None.
|
||||
duration (float | None, optional): _description_. Defaults to None.
|
||||
top (bool, optional): Scroll to top of container. Defaults to False.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
"""
|
||||
parent = self.parent
|
||||
if isinstance(parent, Widget):
|
||||
self.call_later(
|
||||
self.call_after_refresh(
|
||||
parent.scroll_to_widget,
|
||||
self,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
top=top,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def __init_subclass__(
|
||||
@@ -1908,9 +2054,21 @@ class Widget(DOMNode):
|
||||
|
||||
self.check_idle()
|
||||
|
||||
def remove(self) -> None:
|
||||
"""Remove the Widget from the DOM (effectively deleting it)"""
|
||||
self.app.post_message_no_wait(events.Remove(self, widget=self))
|
||||
def remove(self) -> AwaitRemove:
|
||||
"""Remove the Widget from the DOM (effectively deleting it)
|
||||
|
||||
Returns:
|
||||
AwaitRemove: An awaitable object that waits for the widget to be removed.
|
||||
"""
|
||||
prune_finished_event = AsyncEvent()
|
||||
self.app.post_message_no_wait(
|
||||
events.Prune(
|
||||
self,
|
||||
widgets=self.app._detach_from_dom([self]),
|
||||
finished_flag=prune_finished_event,
|
||||
)
|
||||
)
|
||||
return AwaitRemove(prune_finished_event)
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
"""Get renderable for widget.
|
||||
@@ -2059,14 +2217,14 @@ class Widget(DOMNode):
|
||||
self.mouse_over = True
|
||||
|
||||
def _on_focus(self, event: events.Focus) -> None:
|
||||
for node in self.ancestors:
|
||||
for node in self.ancestors_with_self:
|
||||
if node._has_focus_within:
|
||||
self.app.update_styles(node)
|
||||
self.has_focus = True
|
||||
self.refresh()
|
||||
|
||||
def _on_blur(self, event: events.Blur) -> None:
|
||||
if any(node._has_focus_within for node in self.ancestors):
|
||||
if any(node._has_focus_within for node in self.ancestors_with_self):
|
||||
self.app.update_styles(self)
|
||||
self.has_focus = False
|
||||
self.refresh()
|
||||
|
||||
@@ -171,9 +171,9 @@ class Button(Static, can_focus=True):
|
||||
label (str): The text that appears within the button.
|
||||
disabled (bool): Whether the button is disabled or not.
|
||||
variant (ButtonVariant): The variant of the button.
|
||||
name: The name of the button.
|
||||
id: The ID of the button in the DOM.
|
||||
classes: The CSS classes of the button.
|
||||
name (str | None, optional): The name of the button.
|
||||
id (str | None, optional): The ID of the button in the DOM.
|
||||
classes (str | None, optional): The CSS classes of the button.
|
||||
"""
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
|
||||
@@ -269,9 +269,9 @@ class Button(Static, can_focus=True):
|
||||
Args:
|
||||
label (str): The text that appears within the button.
|
||||
disabled (bool): Whether the button is disabled or not.
|
||||
name: The name of the button.
|
||||
id: The ID of the button in the DOM.
|
||||
classes: The CSS classes of the button.
|
||||
name (str | None, optional): The name of the button.
|
||||
id (str | None, optional): The ID of the button in the DOM.
|
||||
classes(str | None, optional): The CSS classes of the button.
|
||||
|
||||
Returns:
|
||||
Button: A Button widget of the 'success' variant.
|
||||
@@ -300,9 +300,9 @@ class Button(Static, can_focus=True):
|
||||
Args:
|
||||
label (str): The text that appears within the button.
|
||||
disabled (bool): Whether the button is disabled or not.
|
||||
name: The name of the button.
|
||||
id: The ID of the button in the DOM.
|
||||
classes: The CSS classes of the button.
|
||||
name (str | None, optional): The name of the button.
|
||||
id (str | None, optional): The ID of the button in the DOM.
|
||||
classes (str | None, optional): The CSS classes of the button.
|
||||
|
||||
Returns:
|
||||
Button: A Button widget of the 'warning' variant.
|
||||
@@ -331,9 +331,9 @@ class Button(Static, can_focus=True):
|
||||
Args:
|
||||
label (str): The text that appears within the button.
|
||||
disabled (bool): Whether the button is disabled or not.
|
||||
name: The name of the button.
|
||||
id: The ID of the button in the DOM.
|
||||
classes: The CSS classes of the button.
|
||||
name (str | None, optional): The name of the button.
|
||||
id (str | None, optional): The ID of the button in the DOM.
|
||||
classes (str | None, optional): The CSS classes of the button.
|
||||
|
||||
Returns:
|
||||
Button: A Button widget of the 'error' variant.
|
||||
|
||||
@@ -15,10 +15,6 @@ from ..scrollbar import ScrollBarRender
|
||||
class Checkbox(Widget, can_focus=True):
|
||||
"""A checkbox widget. Represents a boolean value. Can be toggled by clicking
|
||||
on it or by pressing the enter key or space bar while it has focus.
|
||||
|
||||
Args:
|
||||
value (bool, optional): The initial value of the checkbox. Defaults to False.
|
||||
animate (bool, optional): True if the checkbox should animate when toggled. Defaults to True.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
@@ -66,13 +62,22 @@ class Checkbox(Widget, can_focus=True):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value: bool = None,
|
||||
value: bool = False,
|
||||
*,
|
||||
animate: bool = True,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
):
|
||||
"""Initialise the checkbox.
|
||||
|
||||
Args:
|
||||
value (bool, optional): The initial value of the checkbox. Defaults to False.
|
||||
animate (bool, optional): True if the checkbox should animate when toggled. Defaults to True.
|
||||
name (str | None, optional): The name of the checkbox.
|
||||
id (str | None, optional): The ID of the checkbox in the DOM.
|
||||
classes (str | None, optional): The CSS classes of the checkbox.
|
||||
"""
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
if value:
|
||||
self.slider_pos = 1.0
|
||||
|
||||
@@ -56,7 +56,7 @@ class Column:
|
||||
|
||||
@property
|
||||
def render_width(self) -> int:
|
||||
"""Width in cells, required to render a column."""
|
||||
"""int: Width in cells, required to render a column."""
|
||||
# +2 is to account for space padding either side of the cell
|
||||
if self.auto_width:
|
||||
return self.content_width + 2
|
||||
@@ -88,22 +88,38 @@ class Coord(NamedTuple):
|
||||
column: int
|
||||
|
||||
def left(self) -> Coord:
|
||||
"""Get coordinate to the left."""
|
||||
"""Get coordinate to the left.
|
||||
|
||||
Returns:
|
||||
Coord: The coordinate.
|
||||
"""
|
||||
row, column = self
|
||||
return Coord(row, column - 1)
|
||||
|
||||
def right(self) -> Coord:
|
||||
"""Get coordinate to the right."""
|
||||
"""Get coordinate to the right.
|
||||
|
||||
Returns:
|
||||
Coord: The coordinate.
|
||||
"""
|
||||
row, column = self
|
||||
return Coord(row, column + 1)
|
||||
|
||||
def up(self) -> Coord:
|
||||
"""Get coordinate above."""
|
||||
"""Get coordinate above.
|
||||
|
||||
Returns:
|
||||
Coord: The coordinate.
|
||||
"""
|
||||
row, column = self
|
||||
return Coord(row - 1, column)
|
||||
|
||||
def down(self) -> Coord:
|
||||
"""Get coordinate below."""
|
||||
"""Get coordinate below.
|
||||
|
||||
Returns:
|
||||
Coord: The coordinate.
|
||||
"""
|
||||
row, column = self
|
||||
return Coord(row + 1, column)
|
||||
|
||||
@@ -159,14 +175,32 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
"datatable--cursor",
|
||||
}
|
||||
|
||||
show_header = Reactive(True)
|
||||
fixed_rows = Reactive(0)
|
||||
fixed_columns = Reactive(0)
|
||||
zebra_stripes = Reactive(False)
|
||||
header_height = Reactive(1)
|
||||
show_cursor = Reactive(True)
|
||||
cursor_type = Reactive(CELL)
|
||||
|
||||
cursor_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False)
|
||||
hover_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
show_header: bool = True,
|
||||
fixed_rows: int = 0,
|
||||
fixed_columns: int = 0,
|
||||
zebra_stripes: bool = False,
|
||||
header_height: int = 1,
|
||||
show_cursor: bool = True,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
|
||||
self.columns: list[Column] = []
|
||||
self.rows: dict[int, Row] = {}
|
||||
self.data: dict[int, list[CellType]] = {}
|
||||
@@ -187,16 +221,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
self._require_update_dimensions: bool = False
|
||||
self._new_rows: set[int] = set()
|
||||
|
||||
show_header = Reactive(True)
|
||||
fixed_rows = Reactive(0)
|
||||
fixed_columns = Reactive(0)
|
||||
zebra_stripes = Reactive(False)
|
||||
header_height = Reactive(1)
|
||||
show_cursor = Reactive(True)
|
||||
cursor_type = Reactive(CELL)
|
||||
|
||||
cursor_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False)
|
||||
hover_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False)
|
||||
self.show_header = show_header
|
||||
self.fixed_rows = fixed_rows
|
||||
self.fixed_columns = fixed_columns
|
||||
self.zebra_stripes = zebra_stripes
|
||||
self.header_height = header_height
|
||||
self.show_cursor = show_cursor
|
||||
|
||||
@property
|
||||
def hover_row(self) -> int:
|
||||
@@ -261,6 +291,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
content_width = measure(self.app.console, renderable, 1)
|
||||
column.content_width = max(column.content_width, content_width)
|
||||
|
||||
self._clear_caches()
|
||||
total_width = sum(column.render_width for column in self.columns)
|
||||
header_height = self.header_height if self.show_header else 0
|
||||
self.virtual_size = Size(
|
||||
@@ -281,6 +312,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
cell_region = Region(x, y, width, height)
|
||||
return cell_region
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the table.
|
||||
|
||||
Args:
|
||||
columns (bool, optional): Also clear the columns. Defaults to False.
|
||||
"""
|
||||
self.row_count = 0
|
||||
self._clear_caches()
|
||||
self._y_offsets.clear()
|
||||
self.data.clear()
|
||||
self.rows.clear()
|
||||
self._line_no = 0
|
||||
self._require_update_dimensions = True
|
||||
self.refresh()
|
||||
|
||||
def add_columns(self, *labels: TextType) -> None:
|
||||
"""Add a number of columns.
|
||||
|
||||
@@ -355,6 +401,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
new_rows = self._new_rows.copy()
|
||||
self._new_rows.clear()
|
||||
self._update_dimensions(new_rows)
|
||||
self.refresh()
|
||||
|
||||
def refresh_cell(self, row_index: int, column_index: int) -> None:
|
||||
"""Refresh a cell.
|
||||
|
||||
@@ -92,7 +92,7 @@ class DirectoryTree(TreeControl[DirEntry]):
|
||||
self.render_tree_label.cache_clear()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.call_later(self.load_directory, self.root)
|
||||
self.call_after_refresh(self.load_directory, self.root)
|
||||
|
||||
async def load_directory(self, node: TreeNode[DirEntry]):
|
||||
path = node.data.path
|
||||
|
||||
File diff suppressed because one or more lines are too long
39
tests/test_animation.py
Normal file
39
tests/test_animation.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from time import perf_counter
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class AnimApp(App):
|
||||
CSS = """
|
||||
#foo {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("foo", id="foo")
|
||||
|
||||
|
||||
async def test_animate_height() -> None:
|
||||
"""Test animating styles.height works."""
|
||||
|
||||
# Styles.height is a scalar, which makes it more complicated to animate
|
||||
|
||||
app = AnimApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
static = app.query_one(Static)
|
||||
assert static.size.height == 1
|
||||
assert static.styles.height.value == 1
|
||||
static.styles.animate("height", 100, duration=0.5, easing="linear")
|
||||
start = perf_counter()
|
||||
|
||||
# Wait for the animation to finished
|
||||
await pilot.wait_for_animation()
|
||||
elapsed = perf_counter() - start
|
||||
# Check that the full time has elapsed
|
||||
assert elapsed >= 0.5
|
||||
# Check the height reached the maximum
|
||||
assert static.styles.height.value == 100
|
||||
41
tests/test_call_later.py
Normal file
41
tests/test_call_later.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import asyncio
|
||||
from textual.app import App
|
||||
|
||||
|
||||
class CallLaterApp(App[None]):
|
||||
def __init__(self) -> None:
|
||||
self.display_count = 0
|
||||
super().__init__()
|
||||
|
||||
def post_display_hook(self) -> None:
|
||||
self.display_count += 1
|
||||
|
||||
|
||||
async def test_call_later() -> None:
|
||||
"""Check that call later makes a call."""
|
||||
app = CallLaterApp()
|
||||
called_event = asyncio.Event()
|
||||
|
||||
async with app.run_test():
|
||||
app.call_later(called_event.set)
|
||||
await asyncio.wait_for(called_event.wait(), 1)
|
||||
|
||||
|
||||
async def test_call_after_refresh() -> None:
|
||||
"""Check that call later makes a call after a refresh."""
|
||||
app = CallLaterApp()
|
||||
|
||||
display_count = -1
|
||||
|
||||
called_event = asyncio.Event()
|
||||
|
||||
def callback() -> None:
|
||||
nonlocal display_count
|
||||
called_event.set()
|
||||
display_count = app.display_count
|
||||
|
||||
async with app.run_test():
|
||||
app.call_after_refresh(callback)
|
||||
await asyncio.wait_for(called_event.wait(), 1)
|
||||
app_display_count = app.display_count
|
||||
assert app_display_count > display_count
|
||||
26
tests/test_reactive.py
Normal file
26
tests/test_reactive.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.reactive import reactive
|
||||
|
||||
|
||||
class WatchApp(App):
|
||||
|
||||
count = reactive(0, init=False)
|
||||
|
||||
test_count = 0
|
||||
|
||||
def watch_count(self, value: int) -> None:
|
||||
self.test_count = value
|
||||
|
||||
|
||||
async def test_watch():
|
||||
"""Test that changes to a watched reactive attribute happen immediately."""
|
||||
app = WatchApp()
|
||||
async with app.run_test():
|
||||
app.count += 1
|
||||
assert app.test_count == 1
|
||||
app.count += 1
|
||||
assert app.test_count == 2
|
||||
app.count -= 1
|
||||
assert app.test_count == 1
|
||||
app.count -= 1
|
||||
assert app.test_count == 0
|
||||
45
tests/test_table.py
Normal file
45
tests/test_table.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import asyncio
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import DataTable
|
||||
|
||||
|
||||
class TableApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield DataTable()
|
||||
|
||||
|
||||
async def test_table_clear() -> None:
|
||||
"""Check DataTable.clear"""
|
||||
|
||||
app = TableApp()
|
||||
async with app.run_test() as pilot:
|
||||
table = app.query_one(DataTable)
|
||||
table.add_columns("foo", "bar")
|
||||
assert table.row_count == 0
|
||||
table.add_row("Hello", "World!")
|
||||
assert table.data == {0: ["Hello", "World!"]}
|
||||
assert table.row_count == 1
|
||||
table.clear()
|
||||
assert table.data == {}
|
||||
assert table.row_count == 0
|
||||
|
||||
|
||||
async def test_table_add_row() -> None:
|
||||
|
||||
app = TableApp()
|
||||
async with app.run_test():
|
||||
table = app.query_one(DataTable)
|
||||
table.add_columns("foo", "bar")
|
||||
|
||||
assert table.columns[0].width == 3
|
||||
assert table.columns[1].width == 3
|
||||
table.add_row("Hello", "World!")
|
||||
await asyncio.sleep(0)
|
||||
assert table.columns[0].content_width == 5
|
||||
assert table.columns[1].content_width == 6
|
||||
|
||||
table.add_row("Hello World!!!", "fo")
|
||||
await asyncio.sleep(0)
|
||||
assert table.columns[0].content_width == 14
|
||||
assert table.columns[1].content_width == 6
|
||||
@@ -10,7 +10,7 @@ async def test_unmount():
|
||||
|
||||
class UnmountWidget(Container):
|
||||
def on_unmount(self, event: events.Unmount):
|
||||
unmount_ids.append(f"{self.__class__.__name__}#{self.id}")
|
||||
unmount_ids.append(f"{self.__class__.__name__}#{self.id}-{self.parent is not None}-{len(self.children)}")
|
||||
|
||||
class MyScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
@@ -36,13 +36,13 @@ async def test_unmount():
|
||||
await pilot.exit(None)
|
||||
|
||||
expected = [
|
||||
"UnmountWidget#bar1",
|
||||
"UnmountWidget#bar2",
|
||||
"UnmountWidget#baz1",
|
||||
"UnmountWidget#baz2",
|
||||
"UnmountWidget#bar",
|
||||
"UnmountWidget#baz",
|
||||
"UnmountWidget#top",
|
||||
"UnmountWidget#bar1-True-0",
|
||||
"UnmountWidget#bar2-True-0",
|
||||
"UnmountWidget#baz1-True-0",
|
||||
"UnmountWidget#baz2-True-0",
|
||||
"UnmountWidget#bar-True-0",
|
||||
"UnmountWidget#baz-True-0",
|
||||
"UnmountWidget#top-True-0",
|
||||
"MyScreen#main",
|
||||
]
|
||||
|
||||
|
||||
100
tests/test_widget_child_moving.py
Normal file
100
tests/test_widget_child_moving.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
|
||||
from textual.app import App
|
||||
from textual.widget import Widget, WidgetError
|
||||
|
||||
async def test_widget_move_child() -> None:
|
||||
"""Test moving a widget in a child list."""
|
||||
|
||||
# Test calling move_child with no direction.
|
||||
async with App().run_test() as pilot:
|
||||
child = Widget(Widget())
|
||||
await pilot.app.mount(child)
|
||||
with pytest.raises(WidgetError):
|
||||
pilot.app.screen.move_child(child)
|
||||
|
||||
# Test calling move_child with more than one direction.
|
||||
async with App().run_test() as pilot:
|
||||
child = Widget(Widget())
|
||||
await pilot.app.mount(child)
|
||||
with pytest.raises(WidgetError):
|
||||
pilot.app.screen.move_child(child, before=1, after=2)
|
||||
|
||||
# Test attempting to move a child that isn't ours.
|
||||
async with App().run_test() as pilot:
|
||||
child = Widget(Widget())
|
||||
await pilot.app.mount(child)
|
||||
with pytest.raises(WidgetError):
|
||||
pilot.app.screen.move_child(Widget(), before=child)
|
||||
|
||||
# Test attempting to move relative to a widget that isn't a child.
|
||||
async with App().run_test() as pilot:
|
||||
child = Widget(Widget())
|
||||
await pilot.app.mount(child)
|
||||
with pytest.raises(WidgetError):
|
||||
pilot.app.screen.move_child(child, before=Widget())
|
||||
|
||||
# Make a background set of widgets.
|
||||
widgets = [Widget(id=f"widget-{n}") for n in range( 10 )]
|
||||
|
||||
# Test attempting to move past the end of the child list.
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
with pytest.raises(WidgetError):
|
||||
container.move_child(widgets[0], before=len(widgets)+10)
|
||||
|
||||
# Test attempting to move before the end of the child list.
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
with pytest.raises(WidgetError):
|
||||
container.move_child(widgets[0], before=-(len(widgets)+10))
|
||||
|
||||
# Test the different permutations of moving one widget before another.
|
||||
perms = (
|
||||
( 1, 0 ),
|
||||
( widgets[1], 0 ),
|
||||
( 1, widgets[ 0 ] ),
|
||||
( widgets[ 1 ], widgets[ 0 ])
|
||||
)
|
||||
for child, target in perms:
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
container.move_child(child, before=target)
|
||||
assert container.children[0].id == "widget-1"
|
||||
assert container.children[1].id == "widget-0"
|
||||
assert container.children[2].id == "widget-2"
|
||||
|
||||
# Test the different permutations of moving one widget after another.
|
||||
perms = (
|
||||
( 0, 1 ),
|
||||
( widgets[0], 1 ),
|
||||
( 0, widgets[ 1 ] ),
|
||||
( widgets[ 0 ], widgets[ 1 ])
|
||||
)
|
||||
for child, target in perms:
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
container.move_child(child, after=target)
|
||||
assert container.children[0].id == "widget-1"
|
||||
assert container.children[1].id == "widget-0"
|
||||
assert container.children[2].id == "widget-2"
|
||||
|
||||
# Test moving after a child after the last child.
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
container.move_child(widgets[0], after=widgets[-1])
|
||||
assert container.children[0].id == "widget-1"
|
||||
assert container.children[-1].id == "widget-0"
|
||||
|
||||
# Test moving after a child after the last child's numeric position.
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
container.move_child(widgets[0], after=widgets[9])
|
||||
assert container.children[0].id == "widget-1"
|
||||
assert container.children[-1].id == "widget-0"
|
||||
146
tests/test_widget_removing.py
Normal file
146
tests/test_widget_removing.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import asyncio
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static, Button
|
||||
from textual.containers import Container
|
||||
|
||||
async def test_remove_single_widget():
|
||||
"""It should be possible to the only widget on a screen."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(Static())
|
||||
assert len(pilot.app.screen.children) == 1
|
||||
await pilot.app.query_one(Static).remove()
|
||||
assert len(pilot.app.screen.children) == 0
|
||||
|
||||
async def test_many_remove_all_widgets():
|
||||
"""It should be possible to remove all widgets on a multi-widget screen."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(*[Static() for _ in range(1000)])
|
||||
assert len(pilot.app.screen.children) == 1000
|
||||
await pilot.app.query(Static).remove()
|
||||
assert len(pilot.app.screen.children) == 0
|
||||
|
||||
async def test_many_remove_some_widgets():
|
||||
"""It should be possible to remove some widgets on a multi-widget screen."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(*[Static(id=f"is-{n%2}") for n in range(1000)])
|
||||
assert len(pilot.app.screen.children) == 1000
|
||||
await pilot.app.query("#is-0").remove()
|
||||
assert len(pilot.app.screen.children) == 500
|
||||
|
||||
async def test_remove_branch():
|
||||
"""It should be possible to remove a whole branch in the DOM."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Static()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
Static(),
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Static()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 13
|
||||
await pilot.app.screen.children[0].remove()
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 7
|
||||
|
||||
async def test_remove_overlap():
|
||||
"""It should be possible to remove an overlapping collection of widgets."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Static()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
Static(),
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Static()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 13
|
||||
await pilot.app.query(Container).remove()
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 1
|
||||
|
||||
async def test_remove_move_focus():
|
||||
"""Removing a focused widget should settle focus elsewhere."""
|
||||
async with App().run_test() as pilot:
|
||||
buttons = [ Button(str(n)) for n in range(10)]
|
||||
await pilot.app.mount(Container(*buttons[:5]), Container(*buttons[5:]))
|
||||
assert len(pilot.app.screen.children) == 2
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 12
|
||||
assert pilot.app.focused is None
|
||||
await pilot.press( "tab" )
|
||||
assert pilot.app.focused is not None
|
||||
assert pilot.app.focused == buttons[0]
|
||||
await pilot.app.screen.children[0].remove()
|
||||
assert len(pilot.app.screen.children) == 1
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 6
|
||||
assert pilot.app.focused is not None
|
||||
assert pilot.app.focused == buttons[9]
|
||||
|
||||
async def test_widget_remove_order():
|
||||
"""A Widget.remove of a top-level widget should cause bottom-first removal."""
|
||||
|
||||
removals: list[str] = []
|
||||
|
||||
class Removable(Container):
|
||||
def on_unmount( self, _ ):
|
||||
removals.append(self.id if self.id is not None else "unknown")
|
||||
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(
|
||||
Removable(Removable(Removable(id="grandchild"), id="child"), id="parent")
|
||||
)
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 3
|
||||
await pilot.app.screen.children[0].remove()
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 0
|
||||
assert removals == ["grandchild", "child", "parent"]
|
||||
|
||||
async def test_query_remove_order():
|
||||
"""A DOMQuery.remove of a top-level widget should cause bottom-first removal."""
|
||||
|
||||
removals: list[str] = []
|
||||
|
||||
class Removable(Container):
|
||||
def on_unmount( self, _ ):
|
||||
removals.append(self.id if self.id is not None else "unknown")
|
||||
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(
|
||||
Removable(Removable(Removable(id="grandchild"), id="child"), id="parent")
|
||||
)
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 3
|
||||
await pilot.app.query(Removable).remove()
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 0
|
||||
assert removals == ["grandchild", "child", "parent"]
|
||||
Reference in New Issue
Block a user