Merge branch 'main' of github.com:Textualize/textual into list-view

This commit is contained in:
Darren Burns
2022-11-15 15:56:42 +00:00
43 changed files with 1624 additions and 889 deletions

6
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View 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)

View File

@@ -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
View File

@@ -0,0 +1 @@
::: textual.walk

View File

@@ -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__":

View File

@@ -46,6 +46,6 @@ width: auto
```python
self.styles.height = 10
self.styles.height = "50%
self.styles.height = "50%"
self.styles.height = "auto"
```

View File

@@ -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
View File

@@ -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"},

View File

@@ -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"}

View File

@@ -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:

View File

@@ -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"),
)

View File

@@ -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

View File

@@ -128,3 +128,4 @@ EASING = {
}
DEFAULT_EASING = "in_out_cubic"
DEFAULT_SCROLL_EASING = "out_cubic"

View File

@@ -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
]

View File

@@ -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.

View File

@@ -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.

View 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__()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)),
)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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",

View File

@@ -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.

View File

@@ -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):

View File

@@ -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)

View File

@@ -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.

View File

@@ -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(

View File

@@ -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
View 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)

View File

@@ -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()

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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
View 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
View 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
View 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

View File

@@ -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",
]

View 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"

View 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"]