Merge pull request #591 from Textualize/table-caret

Add a cursor to table
This commit is contained in:
Will McGugan
2022-06-28 13:59:46 +01:00
committed by GitHub
19 changed files with 1149 additions and 230 deletions

168
poetry.lock generated
View File

@@ -74,7 +74,7 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>
[[package]]
name = "black"
version = "22.3.0"
version = "22.6.0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
@@ -85,7 +85,7 @@ click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""}
typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
@@ -208,14 +208,14 @@ dev = ["twine", "markdown", "flake8", "wheel"]
[[package]]
name = "griffe"
version = "0.20.0"
version = "0.21.0"
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
cached_property = {version = "*", markers = "python_version < \"3.8\""}
cached-property = {version = "*", markers = "python_version < \"3.8\""}
[package.extras]
async = ["aiofiles (>=0.7,<1.0)"]
@@ -241,7 +241,7 @@ python-versions = ">=3.5"
[[package]]
name = "importlib-metadata"
version = "4.11.4"
version = "4.12.0"
description = "Read metadata from Python packages"
category = "main"
optional = false
@@ -254,7 +254,7 @@ zipp = ">=0.5"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
perf = ["ipython"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
[[package]]
name = "iniconfig"
@@ -345,7 +345,7 @@ mkdocs = ">=1.1"
[[package]]
name = "mkdocs-material"
version = "8.3.6"
version = "8.3.8"
description = "Documentation that simply works"
category = "dev"
optional = false
@@ -446,11 +446,11 @@ python-versions = "*"
[[package]]
name = "nodeenv"
version = "1.6.0"
version = "1.7.0"
description = "Node.js virtual environment builder"
category = "dev"
optional = false
python-versions = "*"
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
[[package]]
name = "packaging"
@@ -678,7 +678,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "time-machine"
version = "2.7.0"
version = "2.7.1"
description = "Travel through time in your tests."
category = "dev"
optional = false
@@ -721,7 +721,7 @@ python-versions = ">=3.7"
[[package]]
name = "virtualenv"
version = "20.14.1"
version = "20.15.0"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
@@ -878,29 +878,29 @@ attrs = [
{file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
]
black = [
{file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"},
{file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"},
{file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"},
{file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"},
{file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"},
{file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"},
{file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"},
{file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"},
{file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"},
{file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"},
{file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"},
{file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"},
{file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"},
{file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"},
{file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"},
{file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"},
{file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"},
{file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"},
{file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"},
{file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"},
{file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"},
{file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"},
{file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"},
{file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"},
{file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"},
{file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"},
{file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"},
{file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"},
{file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"},
{file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"},
{file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"},
{file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"},
{file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"},
{file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"},
{file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"},
{file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"},
{file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"},
{file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"},
{file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"},
{file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"},
{file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"},
{file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"},
{file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"},
{file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"},
{file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"},
{file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"},
]
cached-property = [
{file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"},
@@ -1043,8 +1043,8 @@ ghp-import = [
{file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
]
griffe = [
{file = "griffe-0.20.0-py3-none-any.whl", hash = "sha256:899e0c9c09baf22b31de1c969a03edaf0ddf72d0a7183df8de746b6c26ed62f4"},
{file = "griffe-0.20.0.tar.gz", hash = "sha256:bf181de6e661c0d2a229c1dc7e90db0def280ee3a89c6829fcc1695baee65f7f"},
{file = "griffe-0.21.0-py3-none-any.whl", hash = "sha256:e9fb5eeb7c721e1d84804452bdc742bd57b120b13aba663157668ae2d217088a"},
{file = "griffe-0.21.0.tar.gz", hash = "sha256:61ab3bc02b09afeb489f1aef44c646a09f1837d9cdf15943ac6021903a4d3984"},
]
identify = [
{file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"},
@@ -1055,8 +1055,8 @@ idna = [
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
]
importlib-metadata = [
{file = "importlib_metadata-4.11.4-py3-none-any.whl", hash = "sha256:c58c8eb8a762858f49e18436ff552e83914778e50e9d2f1660535ffb364552ec"},
{file = "importlib_metadata-4.11.4.tar.gz", hash = "sha256:5d26852efe48c0a32b0509ffbc583fda1a2266545a78d104a6f4aff3db17d700"},
{file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"},
{file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
@@ -1125,8 +1125,8 @@ mkdocs-autorefs = [
{file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"},
]
mkdocs-material = [
{file = "mkdocs-material-8.3.6.tar.gz", hash = "sha256:be8f95c0dfb927339b55b2cc066423dc0b381be9828ff74a5b02df979a859b66"},
{file = "mkdocs_material-8.3.6-py2.py3-none-any.whl", hash = "sha256:01f3fbab055751b3b75a64b538e86b9ce0c6a0f8d43620f6287dfa16534443e5"},
{file = "mkdocs-material-8.3.8.tar.gz", hash = "sha256:b9cd305c3c29ef758931dae06e4aea0ca9f8bcc8ac6b2d45f10f932a015d6b83"},
{file = "mkdocs_material-8.3.8-py2.py3-none-any.whl", hash = "sha256:949c75fa934d4b9ecc7b519964e58f0c9fc29f2ceb04736c85809cdbc403dfb5"},
]
mkdocs-material-extensions = [
{file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"},
@@ -1285,8 +1285,8 @@ mypy-extensions = [
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
nodeenv = [
{file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
{file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
{file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"},
{file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"},
]
packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
@@ -1393,46 +1393,46 @@ six = [
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
time-machine = [
{file = "time-machine-2.7.0.tar.gz", hash = "sha256:0aa0ccd531d7d98e71f7945b65d26d92d31ab74a21a111b9afe61b981c1eb7b2"},
{file = "time_machine-2.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aac9e9fa665d0ed7d105ddaaebda9ef3a2de30aaaf56cfe894f15ba60de8ae09"},
{file = "time_machine-2.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:93c6baea546a8edffa57dc691cbb61e5070d6ec791ff7b4a025d4f34b9808516"},
{file = "time_machine-2.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f056cc4b9212424eb0796389cb019fb61ed0691674649f824585973f001ab66"},
{file = "time_machine-2.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dacd42e9f4a81a7c9e308874802e801aab1fed119698958fc00920038a652145"},
{file = "time_machine-2.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d607303fe6c046b246f1befa82218624db7e92695950c4f6e11f416b8f61129"},
{file = "time_machine-2.7.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:adf738e2d21b265cab4dcb2dc3c0592087f44614a03657c87a6e79131e3be715"},
{file = "time_machine-2.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e0a774c3d94b6b4dd58a939ae886d4533ca9f308f71ab6e174d41aa092fcd807"},
{file = "time_machine-2.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b1115628d39ebe7af89bb187634f119b58f163ee38d9a8369d779a496ac6105"},
{file = "time_machine-2.7.0-cp310-cp310-win32.whl", hash = "sha256:6ad4b6dfec23acc7b5549fcbe9d632347bd62781471d79e44001242821635393"},
{file = "time_machine-2.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef7cf446a19536a6c4fcdc74c6023bf4c85f6ebe97c63b18b4bf97905bf7919d"},
{file = "time_machine-2.7.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:252f53e72da83e876c004e312d15a2e7c920b84dc60a469ad3c5404c5aa0d2b1"},
{file = "time_machine-2.7.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f39695eb780795f7109ddf6b6c04d97f5610d75b08eb968deed3f0e09c43bb0"},
{file = "time_machine-2.7.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c6236ea7f2744f8b9ceab85c9cd71f0aeb03530e2b47377678c39fcd77aafb6"},
{file = "time_machine-2.7.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39aabce90c4c936abe78efe483a6b2fe99901ff431afbeab65c0815eeff66f0b"},
{file = "time_machine-2.7.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:75cdf9aa1b0fba833ca4ebafd738a88983a6597caa4b6ac4a9bfa83c6ca7d8fc"},
{file = "time_machine-2.7.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:038e642d88ba5b76bfa77ec95bcf0d35d7fc6c6ea264fc0aa36e02eff09d3ad2"},
{file = "time_machine-2.7.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3cca3f11059ab6d39dc33495be94ecceaa3d6760b7a983a5c67e172d6b5f6781"},
{file = "time_machine-2.7.0-cp37-cp37m-win32.whl", hash = "sha256:1d40c3be8b075868e73e09a9f600ecf383ad30c8921806c7dc0915820b8fa1c7"},
{file = "time_machine-2.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e125d7a406f8a3b48ddd3f836cf41be98de446db76147a2da8d1f1e82254946e"},
{file = "time_machine-2.7.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7c35a6971ebfe10ed4141a5e5da98cbb7b0f029385d7d97f47b19772e34cdd8a"},
{file = "time_machine-2.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2c3a7f36e3c12f229c651a9e58e5a034d2360c27bea24bfebf95001ce1359b4"},
{file = "time_machine-2.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16ba783c4836a7dc74221a6b937deb88805ccb208a246a160d716c426c074462"},
{file = "time_machine-2.7.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b7ba3a382b96db3f47c9f93207aefdc2817563e3ec727dbda38399035bcd475"},
{file = "time_machine-2.7.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:514413eb7f0b55bd36a5e2ab67b357430c8038382ddb5896ff67f73668b1a23f"},
{file = "time_machine-2.7.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:62a8ae6dbb9ed623f7e42089661974a8fa256c4c64c5391d1f6c60da2b8d54c8"},
{file = "time_machine-2.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fcebeff41e50445100f3f881699d8deeda8f1c6dd80c0c0381b61977aae9dedc"},
{file = "time_machine-2.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:985e773fa70a03b41ef33e1e77b0a655ca1503b77f60d52b613fe1707636b93d"},
{file = "time_machine-2.7.0-cp38-cp38-win32.whl", hash = "sha256:0579fa83e608ef4f1b16bd63e0a01c2bec5c4b3f8c2349a158af2c886cc8e0b5"},
{file = "time_machine-2.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc735fdfa2cbe00f63ed66c48cf32c13b91b9dd9817bec37bdb9c2df5ea09da8"},
{file = "time_machine-2.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a1ae378d4bd4101edd605e82da4859e0a5c509ddf2df0f8094160d795fc22777"},
{file = "time_machine-2.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:747825e968290c8ec98202a60411586edd9b8669cbbf31f61224240e5951d1fb"},
{file = "time_machine-2.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd4797046126bfa1524a9ef8acac83282ce9365ad2cbcab0843ee2662e103502"},
{file = "time_machine-2.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50585410381f9b9799fdd0ab7b1ac5009a341127cf9e72222bc2cb870eddf44d"},
{file = "time_machine-2.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd0b8cf6ad69ed54d2e11c2d5cc3681c6defc7ce022121312cead610d54685c3"},
{file = "time_machine-2.7.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:72d03615caf11eb47c7efeb48c839e307a18c272637d967b697a02f6561dad3c"},
{file = "time_machine-2.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:fa5a0a01f9562be8a13338255a9497b3c1fc8bb018582fa4a881184a9f480c9a"},
{file = "time_machine-2.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:06473db7145f82992d47f02d04a164abe499ec3f4ab24fc9877d49a7e46ae7a0"},
{file = "time_machine-2.7.0-cp39-cp39-win32.whl", hash = "sha256:b377991a5ead8f4d4c887293fde66cb1e8abb5c709e311c724016d16e2ef4c7d"},
{file = "time_machine-2.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:5ba2f608684b19be35b6bc0dca362eca2ded0b7c6f1ff88ed1e55b8dfdfb869d"},
{file = "time-machine-2.7.1.tar.gz", hash = "sha256:be6c1f0421a77a046db8fae00886fb364f683a86612b71dd5c74b22891590042"},
{file = "time_machine-2.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ae93d2f761435d192bc80c148438a0c4261979db0610cef08dfe2c8d21ca1c67"},
{file = "time_machine-2.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:342b431154fbfb1889f8d7aa3d857373a837106bba395a5cc99123f11a7cea03"},
{file = "time_machine-2.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4011ea76f6ad2f932f00cf9e77a25b575a024d6bc15bcf891a3f9916ceeb6e"},
{file = "time_machine-2.7.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43ae8192d370a90d2246fca565a55633f592b314264c65c5c9151c361b715fb9"},
{file = "time_machine-2.7.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cea12d0592ebbe738db952ce6fd272ed90e7bbb095e802f4f2145f8f0e322fa3"},
{file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:732d5fd2d442fa87538b5a6ca623cb205b9b048d2c9aaf79e5cfc7ec7f637848"},
{file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c34e1f49cad2fd41d42c4aabd3d69a32c79d9a8e0779064554843823cd1fb1e4"},
{file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6af3e81cf663b6d5660953ae59da2bb2ae802452ecbc9907272979ed06253659"},
{file = "time_machine-2.7.1-cp310-cp310-win32.whl", hash = "sha256:10c2937d3556f4358205dac5c7cd2d33832b8b911f3deff050f59e1fe2be3231"},
{file = "time_machine-2.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:200974e9bb8a1cb227ce579caafeaeebb0f9de81758c444cbccc0ea464313caf"},
{file = "time_machine-2.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d5e2376b7922c9d96921709c7e730498b9c69da889f359a465d0c43117b62da3"},
{file = "time_machine-2.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ad9117abe223cdc7b4a4432e0a0cfebb1b351a091ee996c653e90f27a734fce"},
{file = "time_machine-2.7.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:626ef686723147468e84da3edcd67ff757a463250fd35f8f6a8e5b899c43b43d"},
{file = "time_machine-2.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9331946ed13acd50bc484f408e26b8eefa67e3dbca41927d2052f2148d3661d"},
{file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3d0612e0323047f29c23732963d9926f1a95e2ce334d86fecd37c803ac240fc6"},
{file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b474499ad0083252240bc5be13f8116cc2ca8a89d1ca4967ed74a7b5f0883f95"},
{file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7df0857709432585b62d2667c0e6e64029b652e2df776b9fb85223c60dce52c7"},
{file = "time_machine-2.7.1-cp37-cp37m-win32.whl", hash = "sha256:77c8dfe8dc7f45bbfe73494c72f3728d99abec5a020460ad7ffee5247365eba4"},
{file = "time_machine-2.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c1fd1c231377ce076f99c8c16999a95510690f8dbd35db0e5fbbc74a17f84b39"},
{file = "time_machine-2.7.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:462924fb87826882fc7830098e621116599f9259d181a7bbf5a4e49f74ec325b"},
{file = "time_machine-2.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:46bf3b4a52d43289b23f0015a9d8592ddf621a5058e566c275cb060347d430c1"},
{file = "time_machine-2.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30afd5b978d8121334c80fa23119d7bd7c9f954169854edf5103e5c8b38358bb"},
{file = "time_machine-2.7.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:633fb8c47f3cd64690591ca6981e4fdbcaa54c18d8a57a3cdc24638ca98f8216"},
{file = "time_machine-2.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80b6093c3b70d1d1a66b65f18a6e53b233c8dd5d8ffe7ac59e9d048fb1d5e15c"},
{file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e62ed7d78694b7e0a2ab30b3dd52ebf26b03e17d6eda0f231fd77e24307a55a9"},
{file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0eaf024d16482ec211a579fd389cbbd4fedd8a1f0a0c41642508815f880ca3a9"},
{file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2688091ce0c16151faa80625efb34e3096731fbdee6d5284c48c984bce95c311"},
{file = "time_machine-2.7.1-cp38-cp38-win32.whl", hash = "sha256:2e54bf0521b6e397fcaa03060feb187bbe5aa63ac51dbb97d5bc59fb0c4725f8"},
{file = "time_machine-2.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:cee72d9e14d36e4b8da6af1d2d784f14da53f76aeb5066540a38318aa907b551"},
{file = "time_machine-2.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:06322d41d45d86e2dc2520794c95129ff25b8620b33851ed40700c859ebf8c30"},
{file = "time_machine-2.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:193b14daa3b3cf67e6b55d6e2d63c2eb7c1d3f49017704d4b43963b198656888"},
{file = "time_machine-2.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1367a89fb857f68cfa723e236cd47febaf201a3a625ad8423110fe0509d5fca8"},
{file = "time_machine-2.7.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce350f7e8bd51a0bb064180486300283bec5cd1a21a318a8ffe5f7df11735f36"},
{file = "time_machine-2.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68ff623d835760314e279aedc0d19a1dc4dec117c6bca388e1ff077c781256bd"},
{file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:05fecd818d41727d31109a0d039ce07c8311602b45ffc07bffd8ae8b6f266ee5"},
{file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1fe4e604c5effc290c1bbecd3ea98687690d0a88fd98ba93e0246bf19ae2a520"},
{file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff07a5635d42957f2bd7eb5ca6579f64de368c842e754a4d3414520693b75db9"},
{file = "time_machine-2.7.1-cp39-cp39-win32.whl", hash = "sha256:8c6314e7e0ffd7af82c8026786d5551aff973e0c86ec1368b0590be9a7620cad"},
{file = "time_machine-2.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:d50a2620d726788cbde97c58e0f6f61d10337d16d088a1fad789f50a1b5ff4d1"},
]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
@@ -1473,8 +1473,8 @@ typing-extensions = [
{file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"},
]
virtualenv = [
{file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"},
{file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"},
{file = "virtualenv-20.15.0-py2.py3-none-any.whl", hash = "sha256:804cce4de5b8a322f099897e308eecc8f6e2951f1a8e7e2b3598dff865f01336"},
{file = "virtualenv-20.15.0.tar.gz", hash = "sha256:4c44b1d77ca81f8368e2d7414f9b20c428ad16b343ac6d226206c5b84e2b4fcc"},
]
watchdog = [
{file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"},

View File

@@ -112,7 +112,6 @@ class BasicApp(App, css_path="basic.css"):
Tweet(TweetBody()),
Widget(
Static(Syntax(CODE, "python"), classes="code"),
self.scroll_to_target,
classes="scrollable",
),
Error(),

241
sandbox/will/basic.css Normal file
View File

@@ -0,0 +1,241 @@
/* CSS file for basic.py */
* {
transition: color 300ms linear, background 300ms linear;
}
* {
scrollbar-background: $panel-darken-2;
scrollbar-background-hover: $panel-darken-3;
scrollbar-color: $system;
scrollbar-color-active: $accent-darken-1;
scrollbar-size-horizontal: 1;
scrollbar-size-vertical: 2;
}
/* *:hover {
tint: red 30%;
} */
App > Screen {
layout: dock;
docks: side=left/1;
background: $surface;
color: $text-surface;
}
DataTable {
border: solid red;
margin: 1 1;
height: 12;
}
#sidebar {
color: $text-primary;
background: $primary-background;
dock: side;
width: 30;
offset-x: -100%;
layout: dock;
transition: offset 500ms in_out_cubic;
}
#sidebar.-active {
offset-x: 0;
}
#sidebar .title {
height: 3;
background: $primary-background-darken-2;
color: $text-primary-darken-2 ;
border-right: outer $primary-darken-3;
content-align: center middle;
}
#sidebar .user {
height: 8;
background: $primary-background-darken-1;
color: $text-primary-darken-1;
border-right: outer $primary-background-darken-3;
content-align: center middle;
}
#sidebar .content {
background: $primary-background;
color: $text-primary-background;
border-right: outer $primary-background-darken-3;
content-align: center middle;
}
#header {
color: $text-primary-darken-1;
background: $primary-darken-1;
height: 3;
content-align: center middle;
}
#content {
color: $text-background;
background: $background;
layout: vertical;
overflow-y: scroll;
}
Tweet {
height:12;
width: 100%;
margin: 1 3;
background: $panel;
color: $text-panel;
layout: vertical;
/* border: outer $primary; */
padding: 1;
border: wide $panel-darken-2;
overflow: auto;
/* scrollbar-gutter: stable; */
align-horizontal: center;
box-sizing: border-box;
}
.scrollable {
overflow-y: scroll;
margin: 1 2;
height: 20;
align-horizontal: center;
layout: vertical;
}
.code {
height: auto;
}
TweetHeader {
height:1;
background: $accent;
color: $text-accent
}
TweetBody {
width: 100%;
background: $panel;
color: $text-panel;
height: auto;
padding: 0 1 0 0;
}
Tweet.scroll-horizontal TweetBody {
width: 350;
}
.button {
background: $accent;
color: $text-accent;
width:20;
height: 3;
/* border-top: hidden $accent-darken-3; */
border: tall $accent-darken-2;
/* border-left: tall $accent-darken-1; */
/* padding: 1 0 0 0 ; */
transition: background 400ms in_out_cubic, color 400ms in_out_cubic;
}
.button:hover {
background: $accent-lighten-1;
color: $text-accent-lighten-1;
width: 20;
height: 3;
border: tall $accent-darken-1;
/* border-left: tall $accent-darken-3; */
}
#footer {
color: $text-accent;
background: $accent;
height: 1;
border-top: hkey $accent-darken-2;
content-align: center middle;
}
#sidebar .content {
layout: vertical
}
OptionItem {
height: 3;
background: $primary-background;
border-right: outer $primary-background-darken-2;
border-left: blank;
content-align: center middle;
}
OptionItem:hover {
height: 3;
color: $accent;
background: $primary-background-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
text-style: bold;
border-left: outer $accent-darken-2;
}
Error {
width: 100%;
height:3;
background: $error;
color: $text-error;
border-top: hkey $error-darken-2;
border-bottom: hkey $error-darken-2;
margin: 1 3;
text-style: bold;
align-horizontal: center;
}
Warning {
width: 100%;
height:3;
background: $warning;
color: $text-warning-fade-1;
border-top: hkey $warning-darken-2;
border-bottom: hkey $warning-darken-2;
margin: 1 2;
text-style: bold;
align-horizontal: center;
}
Success {
width: 100%;
height:3;
box-sizing: border-box;
background: $success-lighten-3;
color: $text-success-lighten-3-fade-1;
border-top: hkey $success;
border-bottom: hkey $success;
margin: 1 2;
text-style: bold;
align-horizontal: center;
}
.horizontal {
layout: horizontal
}

199
sandbox/will/basic.py Normal file
View File

@@ -0,0 +1,199 @@
from rich.console import RenderableType
from rich.style import Style
from rich.syntax import Syntax
from rich.text import Text
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget
from textual.widgets import Static, DataTable
CODE = '''
class Offset(NamedTuple):
"""A point defined by x and y coordinates."""
x: int = 0
y: int = 0
@property
def is_origin(self) -> bool:
"""Check if the point is at the origin (0, 0)"""
return self == (0, 0)
def __bool__(self) -> bool:
return self != (0, 0)
def __add__(self, other: object) -> Offset:
if isinstance(other, tuple):
_x, _y = self
x, y = other
return Offset(_x + x, _y + y)
return NotImplemented
def __sub__(self, other: object) -> Offset:
if isinstance(other, tuple):
_x, _y = self
x, y = other
return Offset(_x - x, _y - y)
return NotImplemented
def __mul__(self, other: object) -> Offset:
if isinstance(other, (float, int)):
x, y = self
return Offset(int(x * other), int(y * other))
return NotImplemented
'''
lorem_short = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a, 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."""
lorem = (
lorem_short
+ """ 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. 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. """
)
lorem_short_text = Text.from_markup(lorem_short)
lorem_long_text = Text.from_markup(lorem * 2)
class TweetHeader(Widget):
def render(self) -> RenderableType:
return Text("Lorem Impsum", justify="center")
class TweetBody(Widget):
short_lorem = Reactive(False)
def render(self) -> Text:
return lorem_short_text if self.short_lorem else lorem_long_text
class Tweet(Widget):
pass
class OptionItem(Widget):
def render(self) -> Text:
return Text("Option")
class Error(Widget):
def render(self) -> Text:
return Text("This is an error message", justify="center")
class Warning(Widget):
def render(self) -> Text:
return Text("This is a warning message", justify="center")
class Success(Widget):
def render(self) -> Text:
return Text("This is a success message", justify="center")
class BasicApp(App, css_path="basic.css"):
"""A basic app demonstrating CSS"""
def on_load(self):
"""Bind keys here."""
self.bind("s", "toggle_class('#sidebar', '-active')")
def on_mount(self):
"""Build layout here."""
table = DataTable()
self.scroll_to_target = Tweet(TweetBody())
self.mount(
header=Static(
Text.from_markup(
"[b]This is a [u]Textual[/u] app, running in the terminal"
),
),
content=Widget(
Tweet(TweetBody()),
Widget(
Static(Syntax(CODE, "python"), classes="code"),
classes="scrollable",
),
table,
Error(),
Tweet(TweetBody(), classes="scrollbar-size-custom"),
Warning(),
Tweet(TweetBody(), classes="scroll-horizontal"),
Success(),
Tweet(TweetBody(), classes="scroll-horizontal"),
Tweet(TweetBody(), classes="scroll-horizontal"),
Tweet(TweetBody(), classes="scroll-horizontal"),
Tweet(TweetBody(), classes="scroll-horizontal"),
Tweet(TweetBody(), classes="scroll-horizontal"),
),
footer=Widget(),
sidebar=Widget(
Widget(classes="title"),
Widget(classes="user"),
OptionItem(),
OptionItem(),
OptionItem(),
Widget(classes="content"),
),
)
table.add_column("Foo", width=20)
table.add_column("Bar", width=20)
table.add_column("Baz", width=20)
table.add_column("Foo", width=20)
table.add_column("Bar", width=20)
table.add_column("Baz", width=20)
table.zebra_stripes = True
for n in range(100):
table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)])
async def on_key(self, event) -> None:
await self.dispatch_key(event)
def key_d(self):
self.dark = not self.dark
async def key_q(self):
await self.shutdown()
def key_x(self):
self.panic(self.tree)
def key_escape(self):
self.app.bell()
def key_t(self):
# Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one.
tweet_body = self.query("TweetBody").first()
tweet_body.short_lorem = not tweet_body.short_lorem
def key_v(self):
self.get_child(id="content").scroll_to_widget(self.scroll_to_target)
def key_space(self):
self.bell()
app = BasicApp()
if __name__ == "__main__":
app.run()
from textual.geometry import Region
from textual.color import Color
print(Region.intersection.cache_info())
print(Region.overlaps.cache_info())
print(Region.union.cache_info())
print(Region.split_vertical.cache_info())
print(Region.__contains__.cache_info())
from textual.css.scalar import Scalar
print(Scalar.resolve_dimension.cache_info())
from rich.style import Style
from rich.cells import cached_cell_len
print(Style._add.cache_info())
print(cached_cell_len.cache_info())

View File

@@ -36,6 +36,8 @@ test_table.add_row("Dec 16, 2016", "Rogue One: A Star Wars Story", "$1,332,439,8
class TableApp(App):
def compose(self) -> ComposeResult:
table = self.table = DataTable(id="data")
yield table
table.add_column("Foo", width=20)
table.add_column("Bar", width=60)
table.add_column("Baz", width=20)
@@ -47,14 +49,21 @@ class TableApp(App):
height = 1
row = [f"row [b]{n}[/b] col [i]{c}[/i]" for c in range(6)]
if n == 10:
row[1] = Syntax(CODE, "python", line_numbers=True, indent_guides=True)
row[1] = Syntax(
CODE,
"python",
theme="ansi_dark",
line_numbers=True,
indent_guides=True,
)
height = 13
if n == 30:
row[1] = test_table
height = 13
table.add_row(*row, height=height)
yield table
table.focus()
def on_mount(self):
self.bind("d", "toggle_dark")

View File

@@ -186,6 +186,8 @@ class Border:
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:

6
src/textual/_cells.py Normal file
View File

@@ -0,0 +1,6 @@
__all__ = ["cell_len"]
try:
from rich.cells import cached_cell_len as cell_len
except ImportError:
from rich.cells import cell_len

View File

@@ -19,6 +19,7 @@ import sys
from typing import Callable, cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING
import rich.repr
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
from rich.control import Control
from rich.segment import Segment
@@ -27,9 +28,9 @@ from rich.style import Style
from . import errors
from .geometry import Region, Offset, Size
from ._cells import cell_len
from ._profile import timer
from ._loop import loop_last
from ._segment_tools import line_crop
from ._types import Lines
if sys.version_info >= (3, 10):
@@ -98,16 +99,21 @@ class ChopsUpdate:
"""A renderable that applies updated spans to the screen."""
def __init__(
self, chops: list[dict[int, list[Segment] | None]], crop: Region
self,
chops: list[dict[int, list[Segment] | None]],
spans: list[tuple[int, int, int]],
chop_ends: list[list[int]],
) -> None:
"""A renderable which updates chops (fragments of lines).
Args:
chops (list[dict[int, list[Segment] | None]]): A mapping of offsets to list of segments, per line.
crop (Region): Region to restrict update to.
chop_ends (list[list[int]]): A list of the end offsets for each line
"""
self.chops = chops
self.crop = crop
self.spans = spans
self.chop_ends = chop_ends
def __rich_console__(
self, console: Console, options: ConsoleOptions
@@ -115,20 +121,53 @@ class ChopsUpdate:
move_to = Control.move_to
new_line = Segment.line()
chops = self.chops
crop = self.crop
last_y = crop.y_max - 1
x1, x2 = crop.x_extents
for y in crop.y_range:
chop_ends = self.chop_ends
last_y = self.spans[-1][0]
_cell_len = cell_len
for y, x1, x2 in self.spans:
line = chops[y]
for x, segments in line.items():
if segments is not None and x2 > x >= x1:
ends = chop_ends[y]
for end, (x, segments) in zip(ends, line.items()):
# TODO: crop to x extents
if segments is None:
continue
if x > x2 or end <= x1:
continue
if x2 > x >= x1 and end <= x2:
yield move_to(x, y)
yield from segments
continue
iter_segments = iter(segments)
if x < x1:
for segment in iter_segments:
next_x = x + _cell_len(segment.text)
if next_x > x1:
yield move_to(x, y)
yield segment
break
x = next_x
else:
yield move_to(x, y)
if end <= x2:
yield from iter_segments
else:
for segment in iter_segments:
if x >= x2:
break
yield segment
x += _cell_len(segment.text)
if y != last_y:
yield new_line
def __rich_repr__(self) -> rich.repr.Result:
yield None, self.crop
return
yield
@rich.repr.auto(angular=True)
@@ -159,14 +198,6 @@ class Compositor:
# Regions that require an update
self._dirty_regions: set[Region] = set()
def add_dirty_regions(self, regions: Iterable[Region]) -> None:
"""Add dirty regions to be repainted next call to render.
Args:
regions (Iterable[Region]): Regions that are "dirty" (changed since last render).
"""
self._dirty_regions.update(regions)
@classmethod
def _regions_to_spans(
cls, regions: Iterable[Region]
@@ -328,7 +359,7 @@ class Compositor:
sub_clip = clip.intersection(child_region)
# The region covered by children relative to parent widget
total_region = child_region.reset_origin
total_region = child_region.reset_offset
if widget.is_container:
# Arrange the layout
@@ -338,7 +369,7 @@ class Compositor:
# An offset added to all placements
placement_offset = (
container_region.origin + layout_offset - widget.scroll_offset
container_region.offset + layout_offset - widget.scroll_offset
)
# Add all the widgets
@@ -358,7 +389,7 @@ class Compositor:
container_size
):
map[chrome_widget] = MapGeometry(
chrome_region + container_region.origin + layout_offset,
chrome_region + container_region.offset + layout_offset,
order,
clip,
container_size,
@@ -414,7 +445,7 @@ class Compositor:
def get_offset(self, widget: Widget) -> Offset:
"""Get the offset of a widget."""
try:
return self.map[widget].region.origin
return self.map[widget].region.offset
except KeyError:
raise errors.NoWidget("Widget is not in layout")
@@ -506,6 +537,7 @@ class Compositor:
# Sort the cuts for each line
self._cuts = [sorted(set(line_cuts)) for line_cuts in cuts]
return self._cuts
def _get_renders(
@@ -546,9 +578,8 @@ class Compositor:
if not region:
continue
if region in clip:
yield region, clip, widget.render_lines(
Region(0, 0, region.width, region.height)
)
lines = widget.render_lines(Region(0, 0, region.width, region.height))
yield region, clip, lines
elif overlaps(clip, region):
clipped_region = intersection(region, clip)
if not clipped_region:
@@ -615,7 +646,7 @@ class Compositor:
)
# A mapping of cut index to a list of segments for each line
chops: list[dict[int, list[Segment] | None]]
chops = [fromkeys(cut_set) for cut_set in cuts]
chops = [fromkeys(cut_set[:-1]) for cut_set in cuts]
cut_segments: Iterable[list[Segment]]
@@ -631,12 +662,12 @@ class Compositor:
continue
chops_line = chops[y]
if all(chops_line):
continue
first_cut, last_cut = render_region.x_extents
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
cuts_line = cuts[y]
final_cuts = [
cut for cut in cuts_line if (last_cut >= cut >= first_cut)
]
if len(final_cuts) <= 2:
# Two cuts, which means the entire line
cut_segments = [line]
@@ -654,7 +685,8 @@ class Compositor:
render_lines = self._assemble_chops(chops)
return LayoutUpdate(render_lines, screen_region)
else:
return ChopsUpdate(chops, crop)
chop_ends = [cut_set[1:] for cut_set in cuts]
return ChopsUpdate(chops, spans, chop_ends)
def __rich_console__(
self, console: Console, options: ConsoleOptions
@@ -674,7 +706,11 @@ class Compositor:
add_region = regions.append
for widget in self.regions.keys() & widgets:
region, clip = self.regions[widget]
update_region = region.intersection(clip)
if update_region:
add_region(update_region)
self.add_dirty_regions(regions)
offset = region.offset
intersection = clip.intersection
for dirty_region in widget.get_dirty_regions():
update_region = intersection(dirty_region.translate(offset))
if update_region:
add_region(update_region)
self._dirty_regions.update(regions)

View File

@@ -85,5 +85,5 @@ class Layout(ABC):
height = container.height
else:
placements, widgets = widget._arrange(Size(width, container.height))
height = max(placement.region.y_max for placement in placements)
height = max(placement.region.bottom for placement in placements)
return height

View File

@@ -6,10 +6,10 @@ from __future__ import annotations
from rich.segment import Segment
from ._cells import cell_len
def line_crop(
segments: list[Segment], start: int, end: int, total: int
) -> list[Segment]:
def line_crop(segments: list[Segment], start: int, end: int, total: int):
"""Crops a list of segments between two cell offsets.
Args:
@@ -23,13 +23,15 @@ def line_crop(
# This is essentially a specialized version of Segment.divide
# The following line has equivalent functionality (but a little slower)
# return list(Segment.divide(segments, [start, end]))[1]
_cell_len = cell_len
pos = 0
output_segments: list[Segment] = []
add_segment = output_segments.append
iter_segments = iter(segments)
segment: Segment | None = None
for segment in iter_segments:
end_pos = pos + segment.cell_length
end_pos = pos + _cell_len(segment.text)
if end_pos > start:
segment = segment.split_cells(start - pos)[-1]
break
@@ -46,7 +48,7 @@ def line_crop(
pos = start
while segment is not None:
end_pos = pos + segment.cell_length
end_pos = pos + _cell_len(segment.text)
if end_pos < end:
add_segment(segment)
else:

View File

@@ -60,6 +60,7 @@ from .layouts.dock import Dock
from .message_pump import MessagePump
from .reactive import Reactive
from .renderables.blank import Blank
from ._profile import timer
from .screen import Screen
from .widget import Widget
@@ -588,8 +589,21 @@ class App(Generic[ReturnType], DOMNode):
self.check_idle()
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
"""Mount widgets. Widgets specified as positional args, or keywords args. If supplied
as keyword args they will be assigned an id of the key.
"""
self.register(self.screen, *anon_widgets, **widgets)
def mount_all(self, widgets: Iterable[Widget]) -> None:
"""Mount widgets from an iterable.
Args:
widgets (Iterable[Widget]): An iterable of widgets.
"""
for widget in widgets:
self.register(self.screen, widget)
def push_screen(self, screen: Screen | None = None) -> Screen:
"""Push a new screen on the screen stack.
@@ -811,9 +825,9 @@ class App(Generic[ReturnType], DOMNode):
self.set_timer(screenshot_timer, on_screenshot)
def on_mount(self) -> None:
widgets = list(self.compose())
widgets = self.compose()
if widgets:
self.mount(*widgets)
self.mount_all(widgets)
async def on_idle(self) -> None:
"""Perform actions when there are no messages in the queue."""
@@ -911,6 +925,7 @@ class App(Generic[ReturnType], DOMNode):
stylesheet.update(self.app, animate=animate)
self.screen._refresh_layout(self.size, full=True)
@timer("_display")
def _display(self, renderable: RenderableType | None) -> None:
"""Display a renderable within a sync.

View File

@@ -428,7 +428,7 @@ class DOMNode(MessagePump):
for node in self.walk_children():
node._css_styles.reset()
if isinstance(node, Widget):
node.set_dirty()
node._set_dirty()
node._layout_required = True
def add_child(self, node: DOMNode) -> None:

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import sys
from functools import lru_cache
from operator import itemgetter, attrgetter
from typing import Any, Collection, NamedTuple, Tuple, TypeVar, Union, cast
if sys.version_info >= (3, 10):
@@ -77,6 +78,10 @@ class Offset(NamedTuple):
return Offset(int(x * other), int(y * other))
return NotImplemented
def __neg__(self) -> Offset:
x, y = self
return Offset(-x, -y)
def blend(self, destination: Offset, factor: float) -> Offset:
"""Blend (interpolate) to a new point.
@@ -89,7 +94,10 @@ class Offset(NamedTuple):
"""
x1, y1 = self
x2, y2 = destination
return Offset(int(x1 + (x2 - x1) * factor), int((y1 + (y2 - y1) * factor)))
return Offset(
int(x1 + (x2 - x1) * factor),
int(y1 + (y2 - y1) * factor),
)
def get_distance_to(self, other: Offset) -> float:
"""Get the distance to another offset.
@@ -191,7 +199,14 @@ class Region(NamedTuple):
height: int = 0
@classmethod
def from_union(cls, regions: Collection[Region]) -> Region:
def from_union(
cls,
regions: Collection[Region],
_get_x=itemgetter(0),
_get_y=itemgetter(1),
_get_right=attrgetter("right"),
_get_bottom=attrgetter("bottom"),
) -> Region:
"""Create a Region from the union of other regions.
Args:
@@ -202,10 +217,10 @@ class Region(NamedTuple):
"""
if not regions:
raise ValueError("At least one region expected")
min_x = min([region.x for region in regions])
max_x = max([x + width for x, _y, width, _height in regions])
min_y = min([region.y for region in regions])
max_y = max([y + height for _x, y, _width, height in regions])
min_x = min(regions, key=_get_x).x
max_x = max(regions, key=_get_right).right
min_y = min(regions, key=_get_y).y
max_y = max(regions, key=_get_bottom).bottom
return cls(min_x, min_y, max_x - min_x, max_y - min_y)
@classmethod
@@ -224,20 +239,68 @@ class Region(NamedTuple):
return cls(x1, y1, x2 - x1, y2 - y1)
@classmethod
def from_origin(cls, origin: tuple[int, int], size: tuple[int, int]) -> Region:
"""Create a region from origin and size.
def from_offset(cls, offset: tuple[int, int], size: tuple[int, int]) -> Region:
"""Create a region from offset and size.
Args:
origin (Point): Origin (top left point)
offset (Point): Offset (top left point)
size (tuple[int, int]): Dimensions of region.
Returns:
Region: A region instance.
"""
x, y = origin
x, y = offset
width, height = size
return cls(x, y, width, height)
@classmethod
def get_scroll_to_visible(cls, window_region: Region, region: Region) -> Offset:
"""Calculate the smallest offset required to translate a window so that it contains
another region.
This method is used to calculate the required offset to scroll something in to view.
Args:
window_region (Region): The window region.
region (Region): The region to move inside the window.
Returns:
Offset: An offset required to add to region to move it inside window_region.
"""
if region in window_region:
# Region is already inside the window, so no need to move it.
return Offset(0, 0)
window_left, window_top, window_right, window_bottom = window_region.corners
region = region.crop_size(window_region.size)
left, top, right, bottom = region.corners
delta_x = delta_y = 0
if not (
(window_right > left >= window_left)
and (window_right > right >= window_left)
):
# The region does not fit
# The window needs to scroll on the X axis to bring region in to view
delta_x = min(
left - window_left,
left - (window_right - region.width),
key=abs,
)
if not (
(window_bottom > top >= window_top)
and (window_bottom > bottom >= window_top)
):
# The window needs to scroll on the Y axis to bring region in to view
delta_y = min(
top - window_top,
top - (window_bottom - region.height),
key=abs,
)
return Offset(delta_x, delta_y)
def __bool__(self) -> bool:
"""A Region is considered False when it has no area."""
return bool(self.width and self.height)
@@ -265,12 +328,12 @@ class Region(NamedTuple):
return (self.y, self.y + self.height)
@property
def x_max(self) -> int:
def right(self) -> int:
"""Maximum X value (non inclusive)"""
return self.x + self.width
@property
def y_max(self) -> int:
def bottom(self) -> int:
"""Maximum Y value (non inclusive)"""
return self.y + self.height
@@ -280,7 +343,7 @@ class Region(NamedTuple):
return self.width * self.height
@property
def origin(self) -> Offset:
def offset(self) -> Offset:
"""Get the start point of the region."""
return Offset(self.x, self.y)
@@ -309,10 +372,10 @@ class Region(NamedTuple):
@property
def corners(self) -> tuple[int, int, int, int]:
"""Get the maxima and minima of region.
"""Get the top left and bottom right coordinates as a tuple of integers.
Returns:
tuple[int, int, int, int]: A tuple of `(<min x>, <max x>, <min y>, <max y>)`
tuple[int, int, int, int]: A tuple of `(<left>, <top>, <right>, <bottom>)`
"""
x, y, width, height = self
return x, y, x + width, y + height
@@ -328,7 +391,7 @@ class Region(NamedTuple):
return range(self.y, self.y + self.height)
@property
def reset_origin(self) -> Region:
def reset_offset(self) -> Region:
"""An region of the same size at (0, 0)."""
_, _, width, height = self
return Region(0, 0, width, height)
@@ -347,6 +410,32 @@ class Region(NamedTuple):
return Region(x - ox, y - oy, width, height)
return NotImplemented
def at_offset(self, offset: tuple[int, int]) -> Region:
"""Get a new Region with the same size at a given offset.
Args:
offset (tuple[int, int]): An offset.
Returns:
Region: New Region with adjusted offset.
"""
x, y = offset
_x, _y, width, height = self
return Region(x, y, width, height)
def crop_size(self, size: tuple[int, int]) -> Region:
"""Get a region with the same offset, with a size no larger than `size`.
Args:
size (tuple[int, int]): Maximum width and height (WIDTH, HEIGHT).
Returns:
Region: New region that could fit within `size`.
"""
x, y, width1, height1 = self
width2, height2 = size
return Region(x, y, min(width1, width2), min(height1, height2))
def expand(self, size: tuple[int, int]) -> Region:
"""Increase the size of the region by adding a border.
@@ -429,19 +518,19 @@ class Region(NamedTuple):
and (y2 >= oy2 >= y1)
)
def translate(self, x: int = 0, y: int = 0) -> Region:
"""Move the origin of the Region.
def translate(self, offset: tuple[int, int]) -> Region:
"""Move the offset of the Region.
Args:
translate_x (int): Value to add to x coordinate.
translate_y (int): Value to add to y coordinate.
translate (tuple[int, int]): Offset to add to region.
Returns:
Region: A new region shifted by x, y
Region: A new region shifted by (x, y)
"""
self_x, self_y, width, height = self
return Region(self_x + x, self_y + y, width, height)
offset_x, offset_y = offset
return Region(self_x + offset_x, self_y + offset_y, width, height)
@lru_cache(maxsize=4096)
def __contains__(self, other: Any) -> bool:

View File

@@ -253,10 +253,12 @@ class GridLayout(Layout):
offset = (container - size) // 2
return offset
offset_x = align(grid_size.width, container.width, col_align)
offset_y = align(grid_size.height, container.height, row_align)
offset = Offset(
align(grid_size.width, container.width, col_align),
align(grid_size.height, container.height, row_align),
)
region = region.translate(offset_x, offset_y)
region = region.translate(offset)
return region
def get_widgets(self) -> Iterable[Widget]:

View File

@@ -117,15 +117,14 @@ class Screen(Widget):
self._refresh_layout()
self._layout_required = False
self._dirty_widgets.clear()
elif self._dirty_widgets or self._dirty_regions:
elif self._dirty_widgets:
self.update_timer.resume()
def _on_update(self) -> None:
"""Called by the _update_timer."""
# Render widgets together
if self._dirty_widgets or self._dirty_regions:
if self._dirty_widgets:
self._compositor.update_widgets(self._dirty_widgets)
self._compositor.add_dirty_regions(self._dirty_regions)
self.app._display(self._compositor.render())
self._dirty_widgets.clear()
self.update_timer.pause()

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
from typing import Collection
from rich.console import RenderableType
from .geometry import Size
from .geometry import Region, Size
from .widget import Widget
@@ -38,6 +40,16 @@ class ScrollView(Widget):
"""Not transparent, i.e. renders something."""
return False
def get_dirty_regions(self) -> Collection[Region]:
"""Get regions which require a repaint.
Returns:
Collection[Region]: Regions to repaint.
"""
regions = self._dirty_regions.copy()
self._dirty_regions.clear()
return regions
def on_mount(self):
self._refresh_scrollbars()

View File

@@ -5,6 +5,7 @@ from typing import (
Any,
Awaitable,
ClassVar,
Collection,
TYPE_CHECKING,
Callable,
Iterable,
@@ -17,8 +18,6 @@ from rich.console import Console, RenderableType
from rich.measure import Measurement
from rich.padding import Padding
from rich.style import Style
from rich.styled import Styled
from . import errors
from . import events
@@ -29,7 +28,7 @@ from ._context import active_app
from ._types import Lines
from .dom import DOMNode
from ._layout import ArrangeResult
from .geometry import clamp, Offset, Region, Size
from .geometry import clamp, Offset, Region, Size, Spacing
from .layouts.vertical import VerticalLayout
from .message import Message
from . import messages
@@ -109,7 +108,7 @@ class Widget(DOMNode):
self._horizontal_scrollbar: ScrollBar | None = None
self._render_cache = RenderCache(Size(0, 0), [])
self._dirty_regions: list[Region] = []
self._dirty_regions: set[Region] = set()
# Cache the auto content dimensions
# TODO: add mechanism to explicitly clear this
@@ -428,10 +427,33 @@ class Widget(DOMNode):
else 0
)
def set_dirty(self) -> None:
"""Set the Widget as 'dirty' (requiring re-render)."""
self._dirty_regions.clear()
self._dirty_regions.append(self.size.region)
def _set_dirty(self, *regions: Region) -> None:
"""Set the Widget as 'dirty' (requiring re-paint).
Regions should be specified as positional args. If no regions are added, then
the entire widget will be considered dirty.
Args:
*regions (Region): Regions which require a repaint.
"""
if regions:
self._dirty_regions.update(regions)
else:
self._dirty_regions.clear()
# TODO: Does this need to be content region?
# self._dirty_regions.append(self.size.region)
self._dirty_regions.add(self.size.region)
def get_dirty_regions(self) -> Collection[Region]:
"""Get regions which require a repaint.
Returns:
Collection[Region]: Regions to repaint.
"""
regions = self._dirty_regions.copy()
return regions
def scroll_to(
self,
@@ -576,6 +598,7 @@ class Widget(DOMNode):
bool: True if any scrolling has occurred in any descendant, otherwise False.
"""
# TODO: Update this to use scroll_to_region
scrolls = set()
node = widget.parent
@@ -595,9 +618,9 @@ class Widget(DOMNode):
# We can either scroll so the widget is at the top of the container, or so that
# it is at the bottom. We want to pick which has the shortest distance
top_delta = widget_region.origin - container_region.origin
top_delta = widget_region.offset - container_region.origin
bottom_delta = widget_region.origin - (
bottom_delta = widget_region.offset - (
container_region.origin
+ Offset(0, container_region.height - widget_region.height)
)
@@ -624,6 +647,36 @@ class Widget(DOMNode):
return any(scrolls)
def scroll_to_region(
self, region: Region, *, spacing: Spacing | None = None, animate: bool = True
) -> Offset:
"""Scrolls a given region in to view, if required.
This method will scroll the least distance required to move `region` fully within
the scrollable area.
Args:
region (Region): A region that should be visible.
animate (bool, optional): Enable animation. Defaults to True.
spacing (Spacing): Space to subtract from the window region.
Returns:
bool: True if the window was scrolled.
"""
window = self.region.at_offset(self.scroll_offset)
if spacing is not None:
window = window.shrink(spacing)
delta = Region.get_scroll_to_visible(window, region)
if delta:
self.scroll_relative(
delta.x or None,
delta.y or None,
animate=animate,
duration=0.2,
)
return delta
def __init_subclass__(
cls,
can_focus: bool = True,
@@ -655,23 +708,23 @@ class Widget(DOMNode):
"""
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
horizontal_scrollbar_thickness = self.scrollbar_size_horizontal
vertical_scrollbar_thickness = self.scrollbar_size_vertical
scrollbar_size_horizontal = self.styles.scrollbar_size_horizontal
scrollbar_size_vertical = self.styles.scrollbar_size_vertical
if self.styles.scrollbar_gutter == "stable":
# Let's _always_ reserve some space, whether the scrollbar is actually displayed or not:
show_vertical_scrollbar = True
vertical_scrollbar_thickness = self.styles.scrollbar_size_vertical
scrollbar_size_vertical = self.styles.scrollbar_size_vertical
if show_horizontal_scrollbar and show_vertical_scrollbar:
(region, _, _, _) = region.split(
-vertical_scrollbar_thickness,
-horizontal_scrollbar_thickness,
-scrollbar_size_vertical,
-scrollbar_size_horizontal,
)
elif show_vertical_scrollbar:
region, _ = region.split_vertical(-vertical_scrollbar_thickness)
region, _ = region.split_vertical(-scrollbar_size_vertical)
elif show_horizontal_scrollbar:
region, _ = region.split_horizontal(-horizontal_scrollbar_thickness)
region, _ = region.split_horizontal(-scrollbar_size_horizontal)
return region
def _arrange_scrollbars(self, size: Size) -> Iterable[tuple[Widget, Region]]:
@@ -689,6 +742,7 @@ class Widget(DOMNode):
scrollbar_size_horizontal = self.scrollbar_size_horizontal
scrollbar_size_vertical = self.scrollbar_size_vertical
if show_horizontal_scrollbar and show_vertical_scrollbar:
(
_,
@@ -724,18 +778,18 @@ class Widget(DOMNode):
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
watch(self, attribute_name, callback)
def render_styled(self) -> RenderableType:
"""Applies style attributes to the default renderable.
def _style_renderable(self, renderable: RenderableType) -> RenderableType:
"""Applies CSS styles to a renderable by wrapping it in another renderable.
Args:
renderable (RenderableType): Renderable to apply styles to.
Returns:
RenderableType: A new renderable.
RenderableType: An updated renderable.
"""
(base_background, base_color), (background, color) = self.colors
styles = self.styles
renderable = self.render()
content_align = (styles.content_align_horizontal, styles.content_align_vertical)
if content_align != ("left", "top"):
horizontal, vertical = content_align
@@ -771,6 +825,17 @@ class Widget(DOMNode):
return renderable
def render_styled(self) -> RenderableType:
"""Applies style attributes to the default renderable.
Returns:
RenderableType: A new renderable.
"""
renderable = self.render()
renderable = self._style_renderable(renderable)
return renderable
@property
def size(self) -> Size:
return self._size
@@ -798,6 +863,16 @@ class Widget(DOMNode):
except errors.NoWidget:
return Region()
@property
def window_region(self) -> Region:
"""The region within the scrollable area that is currently visible.
Returns:
Region: New region.
"""
window_region = self.region.at_offset(self.scroll_offset)
return window_region
@property
def scroll_offset(self) -> Offset:
return Offset(int(self.scroll_x), int(self.scroll_y))
@@ -892,7 +967,8 @@ class Widget(DOMNode):
def _crop_lines(self, lines: Lines, x1, x2) -> Lines:
width = self.size.width
if (x1, x2) != (0, width):
lines = [line_crop(line, x1, x2, width) for line in lines]
_line_crop = line_crop
lines = [_line_crop(line, x1, x2, width) for line in lines]
return lines
def render_lines(self, crop: Region) -> Lines:
@@ -923,7 +999,9 @@ class Widget(DOMNode):
event.set_forwarded()
await self.post_message(event)
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
def refresh(
self, *regions: Region, repaint: bool = True, layout: bool = False
) -> None:
"""Initiate a refresh of the widget.
This method sets an internal flag to perform a refresh, which will be done on the
@@ -933,13 +1011,14 @@ class Widget(DOMNode):
repaint (bool, optional): Repaint the widget (will call render() again). Defaults to True.
layout (bool, optional): Also layout widgets in the view. Defaults to False.
"""
if layout:
self._layout_required = True
self._clear_arrangement_cache()
if repaint:
self._set_dirty(*regions)
self._content_width_cache = (None, 0)
self._content_height_cache = (None, 0)
self.set_dirty()
self._repaint_required = True
if isinstance(self.parent, Widget) and self.styles.auto_dimensions:
self.parent.refresh(layout=True)

View File

@@ -2,7 +2,8 @@ from __future__ import annotations
from dataclasses import dataclass, field
from itertools import chain
from typing import Callable, ClassVar, Generic, TypeVar, cast
import sys
from typing import ClassVar, Generic, NamedTuple, TypeVar, cast
from rich.console import RenderableType
from rich.padding import Padding
@@ -11,29 +12,48 @@ from rich.segment import Segment
from rich.style import Style
from rich.text import Text, TextType
from .. import events
from .._cache import LRUCache
from .._segment_tools import line_crop
from .._types import Lines
from ..geometry import Region, Size
from ..geometry import clamp, Region, Size, Spacing
from ..reactive import Reactive
from .._profile import timer
from ..scroll_view import ScrollView
from ..widget import Widget
from .. import messages
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
CursorType = Literal["cell", "row", "column"]
CELL: CursorType = "cell"
CellType = TypeVar("CellType")
def default_cell_formatter(obj: object) -> RenderableType | None:
"""Format a cell in to a renderable.
Args:
obj (object): Data for a cell.
Returns:
RenderableType | None: A renderable or None if the object could not be rendered.
"""
if isinstance(obj, str):
return Text.from_markup(obj)
if not is_renderable(obj):
raise TypeError(f"Table cell {obj!r} is not renderable")
return None
return cast(RenderableType, obj)
@dataclass
class Column:
"""Table column."""
label: Text
width: int
visible: bool = False
@@ -42,18 +62,46 @@ class Column:
@dataclass
class Row:
"""Table row."""
index: int
height: int
y: int
cell_renderables: list[RenderableType] = field(default_factory=list)
@dataclass
class Cell:
"""Table cell."""
value: object
class Header(Widget):
pass
class Coord(NamedTuple):
"""An object to represent the cordinate of a cell within the data table."""
row: int
column: int
def left(self) -> Coord:
"""Get coordinate to the left."""
row, column = self
return Coord(row, column - 1)
def right(self) -> Coord:
"""Get coordinate to the right."""
row, column = self
return Coord(row, column + 1)
def up(self) -> Coord:
"""Get coordinate above."""
row, column = self
return Coord(row - 1, column)
def down(self) -> Coord:
"""Get coordinate below."""
row, column = self
return Coord(row + 1, column)
class DataTable(ScrollView, Generic[CellType]):
@@ -82,13 +130,17 @@ class DataTable(ScrollView, Generic[CellType]):
background: $primary 10%;
}
DataTable > .datatable--cursor {
background: $secondary;
color: $text-secondary;
}
.-dark-mode DataTable > .datatable--even-row {
background: $primary 15%;
}
DataTable > .datatable--highlight {
background: $secondary;
color: $text-secondary;
background: $secondary 20%;
}
"""
@@ -98,6 +150,7 @@ class DataTable(ScrollView, Generic[CellType]):
"datatable--odd-row",
"datatable--even-row",
"datatable--highlight",
"datatable--cursor",
}
def __init__(
@@ -114,20 +167,47 @@ class DataTable(ScrollView, Generic[CellType]):
self._y_offsets: list[tuple[int, int]] = []
self._row_render_cache: LRUCache[tuple[int, int, Style], tuple[Lines, Lines]]
self._row_render_cache: LRUCache[
tuple[int, int, Style, int, int], tuple[Lines, Lines]
]
self._row_render_cache = LRUCache(1000)
self._cell_render_cache: LRUCache[tuple[int, int, Style], Lines]
self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Lines]
self._cell_render_cache = LRUCache(10000)
self._line_cache: LRUCache[tuple[int, int, int, int], list[Segment]]
self._line_cache: LRUCache[
tuple[int, int, int, int, int, int, Style], list[Segment]
]
self._line_cache = LRUCache(1000)
self._line_no = 0
show_header = Reactive(True)
fixed_rows = Reactive(0)
fixed_columns = Reactive(1)
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)
@property
def hover_row(self) -> int:
return self.hover_cell.row
@property
def hover_column(self) -> int:
return self.hover_cell.column
@property
def cursor_row(self) -> int:
return self.cursor_cell.row
@property
def cursor_column(self) -> int:
return self.cursor_cell.column
def _clear_caches(self) -> None:
self._row_render_cache.clear()
@@ -141,6 +221,7 @@ class DataTable(ScrollView, Generic[CellType]):
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
self._clear_caches()
self.refresh()
def watch_show_header(self, show_header: bool) -> None:
self._clear_caches()
@@ -151,6 +232,20 @@ class DataTable(ScrollView, Generic[CellType]):
def watch_zebra_stripes(self, zebra_stripes: bool) -> None:
self._clear_caches()
def watch_hover_cell(self, old: Coord, value: Coord) -> None:
self.refresh_cell(*old)
self.refresh_cell(*value)
def watch_cursor_cell(self, old: Coord, value: Coord) -> None:
self.refresh_cell(*old)
self.refresh_cell(*value)
def validate_cursor_cell(self, value: Coord) -> Coord:
row, column = value
row = clamp(row, 0, self.row_count - 1)
column = clamp(column, self.fixed_columns, len(self.columns) - 1)
return Coord(row, column)
def _update_dimensions(self) -> None:
"""Called to recalculate the virtual (scrollable) size."""
total_width = sum(column.width for column in self.columns)
@@ -159,6 +254,18 @@ class DataTable(ScrollView, Generic[CellType]):
len(self._y_offsets) + (self.header_height if self.show_header else 0),
)
def _get_cell_region(self, row_index: int, column_index: int) -> Region:
if row_index not in self.rows:
return Region(0, 0, 0, 0)
row = self.rows[row_index]
x = sum(column.width for column in self.columns[:column_index])
width = self.columns[column_index].width
height = row.height
y = row.y
if self.show_header:
y += self.header_height
return Region(x, y, width, height)
def add_column(self, label: TextType, *, width: int = 10) -> None:
"""Add a column to the table.
@@ -179,15 +286,25 @@ class DataTable(ScrollView, Generic[CellType]):
"""
row_index = self.row_count
self.data[row_index] = list(cells)
self.rows[row_index] = Row(row_index, height=height)
self.rows[row_index] = Row(row_index, height, self._line_no)
for line_no in range(height):
self._y_offsets.append((row_index, line_no))
self.row_count += 1
self._line_no += height
self._update_dimensions()
self.refresh()
def refresh_cell(self, row_index: int, column_index: int) -> None:
if row_index < 0 or column_index < 0:
return
region = self._get_cell_region(row_index, column_index)
if not self.window_region.overlaps(region):
return
region = region.translate(-self.scroll_offset)
self.refresh(region)
def _get_row_renderables(self, row_index: int) -> list[RenderableType]:
"""Get renderables for the given row.
@@ -210,7 +327,13 @@ class DataTable(ScrollView, Generic[CellType]):
return [default_cell_formatter(datum) or empty for datum in data]
def _render_cell(
self, row_index: int, column_index: int, style: Style, width: int
self,
row_index: int,
column_index: int,
style: Style,
width: int,
cursor: bool = False,
hover: bool = False,
) -> Lines:
"""Render the given cell.
@@ -223,7 +346,11 @@ class DataTable(ScrollView, Generic[CellType]):
Returns:
Lines: A list of segments per line.
"""
cell_key = (row_index, column_index, style)
if hover:
style += self.component_styles["datatable--highlight"].node.rich_style
if cursor:
style += self.component_styles["datatable--cursor"].node.rich_style
cell_key = (row_index, column_index, style, cursor, hover)
if cell_key not in self._cell_render_cache:
style += Style.from_meta({"row": row_index, "column": column_index})
height = (
@@ -239,7 +366,12 @@ class DataTable(ScrollView, Generic[CellType]):
return self._cell_render_cache[cell_key]
def _render_row(
self, row_index: int, line_no: int, base_style: Style
self,
row_index: int,
line_no: int,
base_style: Style,
cursor_column: int = -1,
hover_column: int = -1,
) -> tuple[Lines, Lines]:
"""Render a row in to lines for each cell.
@@ -252,7 +384,7 @@ class DataTable(ScrollView, Generic[CellType]):
tuple[Lines, Lines]: Lines for fixed cells, and Lines for scrollable cells.
"""
cache_key = (row_index, line_no, base_style)
cache_key = (row_index, line_no, base_style, cursor_column, hover_column)
if cache_key in self._row_render_cache:
return self._row_render_cache[cache_key]
@@ -281,7 +413,14 @@ class DataTable(ScrollView, Generic[CellType]):
row_style = base_style
scrollable_row = [
render_cell(row_index, column.index, row_style, column.width)[line_no]
render_cell(
row_index,
column.index,
row_style,
column.width,
cursor=cursor_column == column.index,
hover=hover_column == column.index,
)[line_no]
for column in self.columns
]
@@ -302,6 +441,8 @@ class DataTable(ScrollView, Generic[CellType]):
if y < self.header_height:
return (-1, y)
y -= self.header_height
if y > len(self._y_offsets):
raise LookupError("Y coord {y!r} is greater than total height")
return self._y_offsets[y]
def _render_line(
@@ -319,34 +460,42 @@ class DataTable(ScrollView, Generic[CellType]):
list[Segment]: List of segments for rendering.
"""
width = self.content_region.width
width = self.region.width
cache_key = (y, x1, x2, width)
try:
row_index, line_no = self._get_offsets(y)
except LookupError:
return [Segment(" " * width, base_style)]
cursor_column = (
self.cursor_column
if (self.show_cursor and self.cursor_row == row_index)
else -1
)
hover_column = self.hover_column if (self.hover_row == row_index) else -1
cache_key = (y, x1, x2, width, cursor_column, hover_column, base_style)
if cache_key in self._line_cache:
return self._line_cache[cache_key]
row_index, line_no = self._get_offsets(y)
fixed, scrollable = self._render_row(row_index, line_no, base_style)
fixed, scrollable = self._render_row(
row_index,
line_no,
base_style,
cursor_column=cursor_column,
hover_column=hover_column,
)
fixed_width = sum(column.width for column in self.columns[: self.fixed_columns])
fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else []
scrollable_line: list[Segment] = list(chain.from_iterable(scrollable))
segments = fixed_line + line_crop(scrollable_line, x1 + fixed_width, x2, width)
remaining_width = width - (fixed_width + min(width, (x2 - x1 + fixed_width)))
if remaining_width > 0:
segments.append(Segment(" " * remaining_width, base_style))
elif remaining_width < 0:
segments = Segment.adjust_line_length(segments, width, style=base_style)
segments = Segment.adjust_line_length(segments, width, style=base_style)
simplified_segments = list(Segment.simplify(segments))
self._line_cache[cache_key] = simplified_segments
return segments
@timer("render_lines")
def render_lines(self, crop: Region) -> Lines:
"""Render lines within a given region.
@@ -356,9 +505,8 @@ class DataTable(ScrollView, Generic[CellType]):
Returns:
Lines: A list of segments for every line within crop region.
"""
scroll_x, scroll_y = self.scroll_offset
x1, y1, x2, y2 = crop.translate(scroll_x, scroll_y).corners
scroll_y = self.scroll_offset.y
x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners
base_style = self.rich_style
@@ -380,5 +528,58 @@ class DataTable(ScrollView, Generic[CellType]):
return lines
def on_mouse_move(self, event):
print(self.get_style_at(event.x, event.y).meta)
def on_mouse_move(self, event: events.MouseMove):
meta = event.style.meta
if meta:
try:
self.hover_cell = Coord(meta["row"], meta["column"])
except KeyError:
pass
async def on_key(self, event) -> None:
await self.dispatch_key(event)
def _get_cell_border(self) -> Spacing:
top = self.header_height if self.show_header else 0
top += sum(
self.rows[row_index].height
for row_index in range(self.fixed_rows)
if row_index in self.rows
)
left = sum(column.width for column in self.columns[: self.fixed_columns])
return Spacing(top, 0, 0, left)
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
region = self._get_cell_region(self.cursor_row, self.cursor_column)
spacing = self._get_cell_border()
self.scroll_to_region(region, animate=animate, spacing=spacing)
def on_click(self, event: events.Click) -> None:
meta = self.get_style_at(event.x, event.y).meta
if meta:
self.cursor_cell = Coord(meta["row"], meta["column"])
self._scroll_cursor_in_to_view()
def key_down(self, event: events.Key):
self.cursor_cell = self.cursor_cell.down()
event.stop()
event.prevent_default()
self._scroll_cursor_in_to_view()
def key_up(self, event: events.Key):
self.cursor_cell = self.cursor_cell.up()
event.stop()
event.prevent_default()
self._scroll_cursor_in_to_view()
def key_right(self, event: events.Key):
self.cursor_cell = self.cursor_cell.right()
event.stop()
event.prevent_default()
self._scroll_cursor_in_to_view(animate=True)
def key_left(self, event: events.Key):
self.cursor_cell = self.cursor_cell.left()
event.stop()
event.prevent_default()
self._scroll_cursor_in_to_view(animate=True)

View File

@@ -89,6 +89,11 @@ def test_offset_sub():
Offset(1, 1) - "foo"
def test_offset_neg():
assert Offset(0, 0) == Offset(0, 0)
assert -Offset(2, -3) == Offset(-2, 3)
def test_offset_mul():
assert Offset(2, 1) * 2 == Offset(4, 2)
assert Offset(2, 1) * -2 == Offset(-4, -2)
@@ -125,8 +130,21 @@ def test_region_from_union():
assert Region.from_union(regions) == Region(10, 20, 40, 40)
def test_region_from_origin():
assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6)
def test_region_from_offset():
assert Region.from_offset(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6)
@pytest.mark.parametrize(
"window,region,scroll",
[
(Region(0, 0, 200, 100), Region(0, 0, 200, 100), Offset(0, 0)),
(Region(0, 0, 200, 100), Region(0, -100, 10, 10), Offset(0, -100)),
(Region(10, 15, 20, 10), Region(0, 0, 50, 50), Offset(-10, -15)),
],
)
def test_get_scroll_to_visible(window, region, scroll):
assert Region.get_scroll_to_visible(window, region) == scroll
assert region.overlaps(window + scroll)
def test_region_area():
@@ -140,7 +158,7 @@ def test_region_size():
def test_region_origin():
assert Region(1, 2, 3, 4).origin == Offset(1, 2)
assert Region(1, 2, 3, 4).offset == Offset(1, 2)
def test_region_bottom_left():
@@ -167,6 +185,16 @@ def test_region_sub():
Region(1, 2, 3, 4) - "foo"
def test_region_at_offset():
assert Region(10, 10, 30, 40).at_offset((0, 0)) == Region(0, 0, 30, 40)
assert Region(10, 10, 30, 40).at_offset((-15, 30)) == Region(-15, 30, 30, 40)
def test_crop_size():
assert Region(10, 20, 100, 200).crop_size((50, 40)) == Region(10, 20, 50, 40)
assert Region(10, 20, 100, 200).crop_size((500, 40)) == Region(10, 20, 100, 40)
def test_region_overlaps():
assert Region(10, 10, 30, 20).overlaps(Region(0, 0, 20, 20))
assert not Region(10, 10, 5, 5).overlaps(Region(15, 15, 20, 20))
@@ -201,8 +229,8 @@ def test_region_contains_region():
def test_region_translate():
assert Region(1, 2, 3, 4).translate(10, 20) == Region(11, 22, 3, 4)
assert Region(1, 2, 3, 4).translate(y=20) == Region(1, 22, 3, 4)
assert Region(1, 2, 3, 4).translate((10, 20)) == Region(11, 22, 3, 4)
assert Region(1, 2, 3, 4).translate((0, 20)) == Region(1, 22, 3, 4)
def test_region_contains_special():
@@ -259,11 +287,11 @@ def test_region_y_extents():
def test_region_x_max():
assert Region(5, 10, 20, 30).x_max == 25
assert Region(5, 10, 20, 30).right == 25
def test_region_y_max():
assert Region(5, 10, 20, 30).y_max == 40
assert Region(5, 10, 20, 30).bottom == 40
def test_region_x_range():
@@ -274,8 +302,8 @@ def test_region_y_range():
assert Region(5, 10, 20, 30).y_range == range(10, 40)
def test_region_reset_origin():
assert Region(5, 10, 20, 30).reset_origin == Region(0, 0, 20, 30)
def test_region_reset_offset():
assert Region(5, 10, 20, 30).reset_offset == Region(0, 0, 20, 30)
def test_region_expand():