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]] [[package]]
name = "black" name = "black"
version = "22.3.0" version = "22.6.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
category = "dev" category = "dev"
optional = false optional = false
@@ -85,7 +85,7 @@ click = ">=8.0.0"
mypy-extensions = ">=0.4.3" mypy-extensions = ">=0.4.3"
pathspec = ">=0.9.0" pathspec = ">=0.9.0"
platformdirs = ">=2" 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\""} 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\""} typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
@@ -208,14 +208,14 @@ dev = ["twine", "markdown", "flake8", "wheel"]
[[package]] [[package]]
name = "griffe" 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." 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" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
cached_property = {version = "*", markers = "python_version < \"3.8\""} cached-property = {version = "*", markers = "python_version < \"3.8\""}
[package.extras] [package.extras]
async = ["aiofiles (>=0.7,<1.0)"] async = ["aiofiles (>=0.7,<1.0)"]
@@ -241,7 +241,7 @@ python-versions = ">=3.5"
[[package]] [[package]]
name = "importlib-metadata" name = "importlib-metadata"
version = "4.11.4" version = "4.12.0"
description = "Read metadata from Python packages" description = "Read metadata from Python packages"
category = "main" category = "main"
optional = false optional = false
@@ -254,7 +254,7 @@ zipp = ">=0.5"
[package.extras] [package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
perf = ["ipython"] 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]] [[package]]
name = "iniconfig" name = "iniconfig"
@@ -345,7 +345,7 @@ mkdocs = ">=1.1"
[[package]] [[package]]
name = "mkdocs-material" name = "mkdocs-material"
version = "8.3.6" version = "8.3.8"
description = "Documentation that simply works" description = "Documentation that simply works"
category = "dev" category = "dev"
optional = false optional = false
@@ -446,11 +446,11 @@ python-versions = "*"
[[package]] [[package]]
name = "nodeenv" name = "nodeenv"
version = "1.6.0" version = "1.7.0"
description = "Node.js virtual environment builder" description = "Node.js virtual environment builder"
category = "dev" category = "dev"
optional = false optional = false
python-versions = "*" python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
[[package]] [[package]]
name = "packaging" name = "packaging"
@@ -678,7 +678,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]] [[package]]
name = "time-machine" name = "time-machine"
version = "2.7.0" version = "2.7.1"
description = "Travel through time in your tests." description = "Travel through time in your tests."
category = "dev" category = "dev"
optional = false optional = false
@@ -721,7 +721,7 @@ python-versions = ">=3.7"
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.14.1" version = "20.15.0"
description = "Virtual Python Environment builder" description = "Virtual Python Environment builder"
category = "dev" category = "dev"
optional = false optional = false
@@ -878,29 +878,29 @@ attrs = [
{file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
] ]
black = [ black = [
{file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, {file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"},
{file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, {file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"},
{file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, {file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"},
{file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, {file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"},
{file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, {file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"},
{file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, {file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"},
{file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, {file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"},
{file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, {file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"},
{file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, {file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"},
{file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, {file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"},
{file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, {file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"},
{file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, {file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"},
{file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, {file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"},
{file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, {file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"},
{file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, {file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"},
{file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, {file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"},
{file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, {file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"},
{file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, {file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"},
{file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, {file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"},
{file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, {file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"},
{file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, {file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"},
{file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, {file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"},
{file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, {file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"},
] ]
cached-property = [ cached-property = [
{file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, {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"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
] ]
griffe = [ griffe = [
{file = "griffe-0.20.0-py3-none-any.whl", hash = "sha256:899e0c9c09baf22b31de1c969a03edaf0ddf72d0a7183df8de746b6c26ed62f4"}, {file = "griffe-0.21.0-py3-none-any.whl", hash = "sha256:e9fb5eeb7c721e1d84804452bdc742bd57b120b13aba663157668ae2d217088a"},
{file = "griffe-0.20.0.tar.gz", hash = "sha256:bf181de6e661c0d2a229c1dc7e90db0def280ee3a89c6829fcc1695baee65f7f"}, {file = "griffe-0.21.0.tar.gz", hash = "sha256:61ab3bc02b09afeb489f1aef44c646a09f1837d9cdf15943ac6021903a4d3984"},
] ]
identify = [ identify = [
{file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"}, {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"}, {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
] ]
importlib-metadata = [ importlib-metadata = [
{file = "importlib_metadata-4.11.4-py3-none-any.whl", hash = "sha256:c58c8eb8a762858f49e18436ff552e83914778e50e9d2f1660535ffb364552ec"}, {file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"},
{file = "importlib_metadata-4.11.4.tar.gz", hash = "sha256:5d26852efe48c0a32b0509ffbc583fda1a2266545a78d104a6f4aff3db17d700"}, {file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"},
] ]
iniconfig = [ iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {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"}, {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"},
] ]
mkdocs-material = [ mkdocs-material = [
{file = "mkdocs-material-8.3.6.tar.gz", hash = "sha256:be8f95c0dfb927339b55b2cc066423dc0b381be9828ff74a5b02df979a859b66"}, {file = "mkdocs-material-8.3.8.tar.gz", hash = "sha256:b9cd305c3c29ef758931dae06e4aea0ca9f8bcc8ac6b2d45f10f932a015d6b83"},
{file = "mkdocs_material-8.3.6-py2.py3-none-any.whl", hash = "sha256:01f3fbab055751b3b75a64b538e86b9ce0c6a0f8d43620f6287dfa16534443e5"}, {file = "mkdocs_material-8.3.8-py2.py3-none-any.whl", hash = "sha256:949c75fa934d4b9ecc7b519964e58f0c9fc29f2ceb04736c85809cdbc403dfb5"},
] ]
mkdocs-material-extensions = [ mkdocs-material-extensions = [
{file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, {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"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
] ]
nodeenv = [ nodeenv = [
{file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"},
{file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"},
] ]
packaging = [ packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {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"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
] ]
time-machine = [ time-machine = [
{file = "time-machine-2.7.0.tar.gz", hash = "sha256:0aa0ccd531d7d98e71f7945b65d26d92d31ab74a21a111b9afe61b981c1eb7b2"}, {file = "time-machine-2.7.1.tar.gz", hash = "sha256:be6c1f0421a77a046db8fae00886fb364f683a86612b71dd5c74b22891590042"},
{file = "time_machine-2.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aac9e9fa665d0ed7d105ddaaebda9ef3a2de30aaaf56cfe894f15ba60de8ae09"}, {file = "time_machine-2.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ae93d2f761435d192bc80c148438a0c4261979db0610cef08dfe2c8d21ca1c67"},
{file = "time_machine-2.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:93c6baea546a8edffa57dc691cbb61e5070d6ec791ff7b4a025d4f34b9808516"}, {file = "time_machine-2.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:342b431154fbfb1889f8d7aa3d857373a837106bba395a5cc99123f11a7cea03"},
{file = "time_machine-2.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f056cc4b9212424eb0796389cb019fb61ed0691674649f824585973f001ab66"}, {file = "time_machine-2.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4011ea76f6ad2f932f00cf9e77a25b575a024d6bc15bcf891a3f9916ceeb6e"},
{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.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43ae8192d370a90d2246fca565a55633f592b314264c65c5c9151c361b715fb9"},
{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.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.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:adf738e2d21b265cab4dcb2dc3c0592087f44614a03657c87a6e79131e3be715"}, {file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:732d5fd2d442fa87538b5a6ca623cb205b9b048d2c9aaf79e5cfc7ec7f637848"},
{file = "time_machine-2.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e0a774c3d94b6b4dd58a939ae886d4533ca9f308f71ab6e174d41aa092fcd807"}, {file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c34e1f49cad2fd41d42c4aabd3d69a32c79d9a8e0779064554843823cd1fb1e4"},
{file = "time_machine-2.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b1115628d39ebe7af89bb187634f119b58f163ee38d9a8369d779a496ac6105"}, {file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6af3e81cf663b6d5660953ae59da2bb2ae802452ecbc9907272979ed06253659"},
{file = "time_machine-2.7.0-cp310-cp310-win32.whl", hash = "sha256:6ad4b6dfec23acc7b5549fcbe9d632347bd62781471d79e44001242821635393"}, {file = "time_machine-2.7.1-cp310-cp310-win32.whl", hash = "sha256:10c2937d3556f4358205dac5c7cd2d33832b8b911f3deff050f59e1fe2be3231"},
{file = "time_machine-2.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef7cf446a19536a6c4fcdc74c6023bf4c85f6ebe97c63b18b4bf97905bf7919d"}, {file = "time_machine-2.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:200974e9bb8a1cb227ce579caafeaeebb0f9de81758c444cbccc0ea464313caf"},
{file = "time_machine-2.7.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:252f53e72da83e876c004e312d15a2e7c920b84dc60a469ad3c5404c5aa0d2b1"}, {file = "time_machine-2.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d5e2376b7922c9d96921709c7e730498b9c69da889f359a465d0c43117b62da3"},
{file = "time_machine-2.7.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f39695eb780795f7109ddf6b6c04d97f5610d75b08eb968deed3f0e09c43bb0"}, {file = "time_machine-2.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ad9117abe223cdc7b4a4432e0a0cfebb1b351a091ee996c653e90f27a734fce"},
{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.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:626ef686723147468e84da3edcd67ff757a463250fd35f8f6a8e5b899c43b43d"},
{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.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.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:75cdf9aa1b0fba833ca4ebafd738a88983a6597caa4b6ac4a9bfa83c6ca7d8fc"}, {file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3d0612e0323047f29c23732963d9926f1a95e2ce334d86fecd37c803ac240fc6"},
{file = "time_machine-2.7.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:038e642d88ba5b76bfa77ec95bcf0d35d7fc6c6ea264fc0aa36e02eff09d3ad2"}, {file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b474499ad0083252240bc5be13f8116cc2ca8a89d1ca4967ed74a7b5f0883f95"},
{file = "time_machine-2.7.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3cca3f11059ab6d39dc33495be94ecceaa3d6760b7a983a5c67e172d6b5f6781"}, {file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7df0857709432585b62d2667c0e6e64029b652e2df776b9fb85223c60dce52c7"},
{file = "time_machine-2.7.0-cp37-cp37m-win32.whl", hash = "sha256:1d40c3be8b075868e73e09a9f600ecf383ad30c8921806c7dc0915820b8fa1c7"}, {file = "time_machine-2.7.1-cp37-cp37m-win32.whl", hash = "sha256:77c8dfe8dc7f45bbfe73494c72f3728d99abec5a020460ad7ffee5247365eba4"},
{file = "time_machine-2.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e125d7a406f8a3b48ddd3f836cf41be98de446db76147a2da8d1f1e82254946e"}, {file = "time_machine-2.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c1fd1c231377ce076f99c8c16999a95510690f8dbd35db0e5fbbc74a17f84b39"},
{file = "time_machine-2.7.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7c35a6971ebfe10ed4141a5e5da98cbb7b0f029385d7d97f47b19772e34cdd8a"}, {file = "time_machine-2.7.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:462924fb87826882fc7830098e621116599f9259d181a7bbf5a4e49f74ec325b"},
{file = "time_machine-2.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2c3a7f36e3c12f229c651a9e58e5a034d2360c27bea24bfebf95001ce1359b4"}, {file = "time_machine-2.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:46bf3b4a52d43289b23f0015a9d8592ddf621a5058e566c275cb060347d430c1"},
{file = "time_machine-2.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16ba783c4836a7dc74221a6b937deb88805ccb208a246a160d716c426c074462"}, {file = "time_machine-2.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30afd5b978d8121334c80fa23119d7bd7c9f954169854edf5103e5c8b38358bb"},
{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.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:633fb8c47f3cd64690591ca6981e4fdbcaa54c18d8a57a3cdc24638ca98f8216"},
{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.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.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:62a8ae6dbb9ed623f7e42089661974a8fa256c4c64c5391d1f6c60da2b8d54c8"}, {file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e62ed7d78694b7e0a2ab30b3dd52ebf26b03e17d6eda0f231fd77e24307a55a9"},
{file = "time_machine-2.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fcebeff41e50445100f3f881699d8deeda8f1c6dd80c0c0381b61977aae9dedc"}, {file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0eaf024d16482ec211a579fd389cbbd4fedd8a1f0a0c41642508815f880ca3a9"},
{file = "time_machine-2.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:985e773fa70a03b41ef33e1e77b0a655ca1503b77f60d52b613fe1707636b93d"}, {file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2688091ce0c16151faa80625efb34e3096731fbdee6d5284c48c984bce95c311"},
{file = "time_machine-2.7.0-cp38-cp38-win32.whl", hash = "sha256:0579fa83e608ef4f1b16bd63e0a01c2bec5c4b3f8c2349a158af2c886cc8e0b5"}, {file = "time_machine-2.7.1-cp38-cp38-win32.whl", hash = "sha256:2e54bf0521b6e397fcaa03060feb187bbe5aa63ac51dbb97d5bc59fb0c4725f8"},
{file = "time_machine-2.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc735fdfa2cbe00f63ed66c48cf32c13b91b9dd9817bec37bdb9c2df5ea09da8"}, {file = "time_machine-2.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:cee72d9e14d36e4b8da6af1d2d784f14da53f76aeb5066540a38318aa907b551"},
{file = "time_machine-2.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a1ae378d4bd4101edd605e82da4859e0a5c509ddf2df0f8094160d795fc22777"}, {file = "time_machine-2.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:06322d41d45d86e2dc2520794c95129ff25b8620b33851ed40700c859ebf8c30"},
{file = "time_machine-2.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:747825e968290c8ec98202a60411586edd9b8669cbbf31f61224240e5951d1fb"}, {file = "time_machine-2.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:193b14daa3b3cf67e6b55d6e2d63c2eb7c1d3f49017704d4b43963b198656888"},
{file = "time_machine-2.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd4797046126bfa1524a9ef8acac83282ce9365ad2cbcab0843ee2662e103502"}, {file = "time_machine-2.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1367a89fb857f68cfa723e236cd47febaf201a3a625ad8423110fe0509d5fca8"},
{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.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce350f7e8bd51a0bb064180486300283bec5cd1a21a318a8ffe5f7df11735f36"},
{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.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.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:72d03615caf11eb47c7efeb48c839e307a18c272637d967b697a02f6561dad3c"}, {file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:05fecd818d41727d31109a0d039ce07c8311602b45ffc07bffd8ae8b6f266ee5"},
{file = "time_machine-2.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:fa5a0a01f9562be8a13338255a9497b3c1fc8bb018582fa4a881184a9f480c9a"}, {file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1fe4e604c5effc290c1bbecd3ea98687690d0a88fd98ba93e0246bf19ae2a520"},
{file = "time_machine-2.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:06473db7145f82992d47f02d04a164abe499ec3f4ab24fc9877d49a7e46ae7a0"}, {file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff07a5635d42957f2bd7eb5ca6579f64de368c842e754a4d3414520693b75db9"},
{file = "time_machine-2.7.0-cp39-cp39-win32.whl", hash = "sha256:b377991a5ead8f4d4c887293fde66cb1e8abb5c709e311c724016d16e2ef4c7d"}, {file = "time_machine-2.7.1-cp39-cp39-win32.whl", hash = "sha256:8c6314e7e0ffd7af82c8026786d5551aff973e0c86ec1368b0590be9a7620cad"},
{file = "time_machine-2.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:5ba2f608684b19be35b6bc0dca362eca2ded0b7c6f1ff88ed1e55b8dfdfb869d"}, {file = "time_machine-2.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:d50a2620d726788cbde97c58e0f6f61d10337d16d088a1fad789f50a1b5ff4d1"},
] ]
toml = [ toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {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"}, {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"},
] ]
virtualenv = [ virtualenv = [
{file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"}, {file = "virtualenv-20.15.0-py2.py3-none-any.whl", hash = "sha256:804cce4de5b8a322f099897e308eecc8f6e2951f1a8e7e2b3598dff865f01336"},
{file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"}, {file = "virtualenv-20.15.0.tar.gz", hash = "sha256:4c44b1d77ca81f8368e2d7414f9b20c428ad16b343ac6d226206c5b84e2b4fcc"},
] ]
watchdog = [ watchdog = [
{file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"}, {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()), Tweet(TweetBody()),
Widget( Widget(
Static(Syntax(CODE, "python"), classes="code"), Static(Syntax(CODE, "python"), classes="code"),
self.scroll_to_target,
classes="scrollable", classes="scrollable",
), ),
Error(), 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): class TableApp(App):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
table = self.table = DataTable(id="data") table = self.table = DataTable(id="data")
yield table
table.add_column("Foo", width=20) table.add_column("Foo", width=20)
table.add_column("Bar", width=60) table.add_column("Bar", width=60)
table.add_column("Baz", width=20) table.add_column("Baz", width=20)
@@ -47,14 +49,21 @@ class TableApp(App):
height = 1 height = 1
row = [f"row [b]{n}[/b] col [i]{c}[/i]" for c in range(6)] row = [f"row [b]{n}[/b] col [i]{c}[/i]" for c in range(6)]
if n == 10: 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 height = 13
if n == 30: if n == 30:
row[1] = test_table row[1] = test_table
height = 13 height = 13
table.add_row(*row, height=height) table.add_row(*row, height=height)
yield table
table.focus()
def on_mount(self): def on_mount(self):
self.bind("d", "toggle_dark") self.bind("d", "toggle_dark")

View File

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

View File

@@ -85,5 +85,5 @@ class Layout(ABC):
height = container.height height = container.height
else: else:
placements, widgets = widget._arrange(Size(width, container.height)) 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 return height

View File

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

View File

@@ -60,6 +60,7 @@ from .layouts.dock import Dock
from .message_pump import MessagePump from .message_pump import MessagePump
from .reactive import Reactive from .reactive import Reactive
from .renderables.blank import Blank from .renderables.blank import Blank
from ._profile import timer
from .screen import Screen from .screen import Screen
from .widget import Widget from .widget import Widget
@@ -588,8 +589,21 @@ class App(Generic[ReturnType], DOMNode):
self.check_idle() self.check_idle()
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: 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) 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: def push_screen(self, screen: Screen | None = None) -> Screen:
"""Push a new screen on the screen stack. """Push a new screen on the screen stack.
@@ -811,9 +825,9 @@ class App(Generic[ReturnType], DOMNode):
self.set_timer(screenshot_timer, on_screenshot) self.set_timer(screenshot_timer, on_screenshot)
def on_mount(self) -> None: def on_mount(self) -> None:
widgets = list(self.compose()) widgets = self.compose()
if widgets: if widgets:
self.mount(*widgets) self.mount_all(widgets)
async def on_idle(self) -> None: async def on_idle(self) -> None:
"""Perform actions when there are no messages in the queue.""" """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) stylesheet.update(self.app, animate=animate)
self.screen._refresh_layout(self.size, full=True) self.screen._refresh_layout(self.size, full=True)
@timer("_display")
def _display(self, renderable: RenderableType | None) -> None: def _display(self, renderable: RenderableType | None) -> None:
"""Display a renderable within a sync. """Display a renderable within a sync.

View File

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

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import sys import sys
from functools import lru_cache from functools import lru_cache
from operator import itemgetter, attrgetter
from typing import Any, Collection, NamedTuple, Tuple, TypeVar, Union, cast from typing import Any, Collection, NamedTuple, Tuple, TypeVar, Union, cast
if sys.version_info >= (3, 10): if sys.version_info >= (3, 10):
@@ -77,6 +78,10 @@ class Offset(NamedTuple):
return Offset(int(x * other), int(y * other)) return Offset(int(x * other), int(y * other))
return NotImplemented return NotImplemented
def __neg__(self) -> Offset:
x, y = self
return Offset(-x, -y)
def blend(self, destination: Offset, factor: float) -> Offset: def blend(self, destination: Offset, factor: float) -> Offset:
"""Blend (interpolate) to a new point. """Blend (interpolate) to a new point.
@@ -89,7 +94,10 @@ class Offset(NamedTuple):
""" """
x1, y1 = self x1, y1 = self
x2, y2 = destination 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: def get_distance_to(self, other: Offset) -> float:
"""Get the distance to another offset. """Get the distance to another offset.
@@ -191,7 +199,14 @@ class Region(NamedTuple):
height: int = 0 height: int = 0
@classmethod @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. """Create a Region from the union of other regions.
Args: Args:
@@ -202,10 +217,10 @@ class Region(NamedTuple):
""" """
if not regions: if not regions:
raise ValueError("At least one region expected") raise ValueError("At least one region expected")
min_x = min([region.x for region in regions]) min_x = min(regions, key=_get_x).x
max_x = max([x + width for x, _y, width, _height in regions]) max_x = max(regions, key=_get_right).right
min_y = min([region.y for region in regions]) min_y = min(regions, key=_get_y).y
max_y = max([y + height for _x, y, _width, height in regions]) max_y = max(regions, key=_get_bottom).bottom
return cls(min_x, min_y, max_x - min_x, max_y - min_y) return cls(min_x, min_y, max_x - min_x, max_y - min_y)
@classmethod @classmethod
@@ -224,20 +239,68 @@ class Region(NamedTuple):
return cls(x1, y1, x2 - x1, y2 - y1) return cls(x1, y1, x2 - x1, y2 - y1)
@classmethod @classmethod
def from_origin(cls, origin: tuple[int, int], size: tuple[int, int]) -> Region: def from_offset(cls, offset: tuple[int, int], size: tuple[int, int]) -> Region:
"""Create a region from origin and size. """Create a region from offset and size.
Args: Args:
origin (Point): Origin (top left point) offset (Point): Offset (top left point)
size (tuple[int, int]): Dimensions of region. size (tuple[int, int]): Dimensions of region.
Returns: Returns:
Region: A region instance. Region: A region instance.
""" """
x, y = origin x, y = offset
width, height = size width, height = size
return cls(x, y, width, height) 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: def __bool__(self) -> bool:
"""A Region is considered False when it has no area.""" """A Region is considered False when it has no area."""
return bool(self.width and self.height) return bool(self.width and self.height)
@@ -265,12 +328,12 @@ class Region(NamedTuple):
return (self.y, self.y + self.height) return (self.y, self.y + self.height)
@property @property
def x_max(self) -> int: def right(self) -> int:
"""Maximum X value (non inclusive)""" """Maximum X value (non inclusive)"""
return self.x + self.width return self.x + self.width
@property @property
def y_max(self) -> int: def bottom(self) -> int:
"""Maximum Y value (non inclusive)""" """Maximum Y value (non inclusive)"""
return self.y + self.height return self.y + self.height
@@ -280,7 +343,7 @@ class Region(NamedTuple):
return self.width * self.height return self.width * self.height
@property @property
def origin(self) -> Offset: def offset(self) -> Offset:
"""Get the start point of the region.""" """Get the start point of the region."""
return Offset(self.x, self.y) return Offset(self.x, self.y)
@@ -309,10 +372,10 @@ class Region(NamedTuple):
@property @property
def corners(self) -> tuple[int, int, int, int]: 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: 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 x, y, width, height = self
return x, y, x + width, y + height return x, y, x + width, y + height
@@ -328,7 +391,7 @@ class Region(NamedTuple):
return range(self.y, self.y + self.height) return range(self.y, self.y + self.height)
@property @property
def reset_origin(self) -> Region: def reset_offset(self) -> Region:
"""An region of the same size at (0, 0).""" """An region of the same size at (0, 0)."""
_, _, width, height = self _, _, width, height = self
return Region(0, 0, width, height) return Region(0, 0, width, height)
@@ -347,6 +410,32 @@ class Region(NamedTuple):
return Region(x - ox, y - oy, width, height) return Region(x - ox, y - oy, width, height)
return NotImplemented 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: def expand(self, size: tuple[int, int]) -> Region:
"""Increase the size of the region by adding a border. """Increase the size of the region by adding a border.
@@ -429,19 +518,19 @@ class Region(NamedTuple):
and (y2 >= oy2 >= y1) and (y2 >= oy2 >= y1)
) )
def translate(self, x: int = 0, y: int = 0) -> Region: def translate(self, offset: tuple[int, int]) -> Region:
"""Move the origin of the Region. """Move the offset of the Region.
Args: Args:
translate_x (int): Value to add to x coordinate. translate (tuple[int, int]): Offset to add to region.
translate_y (int): Value to add to y coordinate.
Returns: Returns:
Region: A new region shifted by x, y Region: A new region shifted by (x, y)
""" """
self_x, self_y, width, height = self 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) @lru_cache(maxsize=4096)
def __contains__(self, other: Any) -> bool: def __contains__(self, other: Any) -> bool:

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from typing import Collection
from rich.console import RenderableType from rich.console import RenderableType
from .geometry import Size from .geometry import Region, Size
from .widget import Widget from .widget import Widget
@@ -38,6 +40,16 @@ class ScrollView(Widget):
"""Not transparent, i.e. renders something.""" """Not transparent, i.e. renders something."""
return False 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): def on_mount(self):
self._refresh_scrollbars() self._refresh_scrollbars()

View File

@@ -5,6 +5,7 @@ from typing import (
Any, Any,
Awaitable, Awaitable,
ClassVar, ClassVar,
Collection,
TYPE_CHECKING, TYPE_CHECKING,
Callable, Callable,
Iterable, Iterable,
@@ -17,8 +18,6 @@ from rich.console import Console, RenderableType
from rich.measure import Measurement from rich.measure import Measurement
from rich.padding import Padding from rich.padding import Padding
from rich.style import Style from rich.style import Style
from rich.styled import Styled
from . import errors from . import errors
from . import events from . import events
@@ -29,7 +28,7 @@ from ._context import active_app
from ._types import Lines from ._types import Lines
from .dom import DOMNode from .dom import DOMNode
from ._layout import ArrangeResult 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 .layouts.vertical import VerticalLayout
from .message import Message from .message import Message
from . import messages from . import messages
@@ -109,7 +108,7 @@ class Widget(DOMNode):
self._horizontal_scrollbar: ScrollBar | None = None self._horizontal_scrollbar: ScrollBar | None = None
self._render_cache = RenderCache(Size(0, 0), []) self._render_cache = RenderCache(Size(0, 0), [])
self._dirty_regions: list[Region] = [] self._dirty_regions: set[Region] = set()
# Cache the auto content dimensions # Cache the auto content dimensions
# TODO: add mechanism to explicitly clear this # TODO: add mechanism to explicitly clear this
@@ -428,10 +427,33 @@ class Widget(DOMNode):
else 0 else 0
) )
def set_dirty(self) -> None: def _set_dirty(self, *regions: Region) -> None:
"""Set the Widget as 'dirty' (requiring re-render).""" """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() self._dirty_regions.clear()
self._dirty_regions.append(self.size.region) # 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( def scroll_to(
self, self,
@@ -576,6 +598,7 @@ class Widget(DOMNode):
bool: True if any scrolling has occurred in any descendant, otherwise False. bool: True if any scrolling has occurred in any descendant, otherwise False.
""" """
# TODO: Update this to use scroll_to_region
scrolls = set() scrolls = set()
node = widget.parent 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 # 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 # 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 container_region.origin
+ Offset(0, container_region.height - widget_region.height) + Offset(0, container_region.height - widget_region.height)
) )
@@ -624,6 +647,36 @@ class Widget(DOMNode):
return any(scrolls) 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__( def __init_subclass__(
cls, cls,
can_focus: bool = True, can_focus: bool = True,
@@ -655,23 +708,23 @@ class Widget(DOMNode):
""" """
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
horizontal_scrollbar_thickness = self.scrollbar_size_horizontal scrollbar_size_horizontal = self.styles.scrollbar_size_horizontal
vertical_scrollbar_thickness = self.scrollbar_size_vertical scrollbar_size_vertical = self.styles.scrollbar_size_vertical
if self.styles.scrollbar_gutter == "stable": if self.styles.scrollbar_gutter == "stable":
# Let's _always_ reserve some space, whether the scrollbar is actually displayed or not: # Let's _always_ reserve some space, whether the scrollbar is actually displayed or not:
show_vertical_scrollbar = True 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: if show_horizontal_scrollbar and show_vertical_scrollbar:
(region, _, _, _) = region.split( (region, _, _, _) = region.split(
-vertical_scrollbar_thickness, -scrollbar_size_vertical,
-horizontal_scrollbar_thickness, -scrollbar_size_horizontal,
) )
elif show_vertical_scrollbar: elif show_vertical_scrollbar:
region, _ = region.split_vertical(-vertical_scrollbar_thickness) region, _ = region.split_vertical(-scrollbar_size_vertical)
elif show_horizontal_scrollbar: elif show_horizontal_scrollbar:
region, _ = region.split_horizontal(-horizontal_scrollbar_thickness) region, _ = region.split_horizontal(-scrollbar_size_horizontal)
return region return region
def _arrange_scrollbars(self, size: Size) -> Iterable[tuple[Widget, 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_horizontal = self.scrollbar_size_horizontal
scrollbar_size_vertical = self.scrollbar_size_vertical scrollbar_size_vertical = self.scrollbar_size_vertical
if show_horizontal_scrollbar and show_vertical_scrollbar: 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: def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
watch(self, attribute_name, callback) watch(self, attribute_name, callback)
def render_styled(self) -> RenderableType: def _style_renderable(self, renderable: RenderableType) -> RenderableType:
"""Applies style attributes to the default renderable. """Applies CSS styles to a renderable by wrapping it in another renderable.
Args:
renderable (RenderableType): Renderable to apply styles to.
Returns: Returns:
RenderableType: A new renderable. RenderableType: An updated renderable.
""" """
(base_background, base_color), (background, color) = self.colors (base_background, base_color), (background, color) = self.colors
styles = self.styles styles = self.styles
renderable = self.render()
content_align = (styles.content_align_horizontal, styles.content_align_vertical) content_align = (styles.content_align_horizontal, styles.content_align_vertical)
if content_align != ("left", "top"): if content_align != ("left", "top"):
horizontal, vertical = content_align horizontal, vertical = content_align
@@ -771,6 +825,17 @@ class Widget(DOMNode):
return renderable 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 @property
def size(self) -> Size: def size(self) -> Size:
return self._size return self._size
@@ -798,6 +863,16 @@ class Widget(DOMNode):
except errors.NoWidget: except errors.NoWidget:
return Region() 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 @property
def scroll_offset(self) -> Offset: def scroll_offset(self) -> Offset:
return Offset(int(self.scroll_x), int(self.scroll_y)) 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: def _crop_lines(self, lines: Lines, x1, x2) -> Lines:
width = self.size.width width = self.size.width
if (x1, x2) != (0, 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 return lines
def render_lines(self, crop: Region) -> Lines: def render_lines(self, crop: Region) -> Lines:
@@ -923,7 +999,9 @@ class Widget(DOMNode):
event.set_forwarded() event.set_forwarded()
await self.post_message(event) 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. """Initiate a refresh of the widget.
This method sets an internal flag to perform a refresh, which will be done on the 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. 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. layout (bool, optional): Also layout widgets in the view. Defaults to False.
""" """
if layout: if layout:
self._layout_required = True self._layout_required = True
self._clear_arrangement_cache() self._clear_arrangement_cache()
if repaint: if repaint:
self._set_dirty(*regions)
self._content_width_cache = (None, 0) self._content_width_cache = (None, 0)
self._content_height_cache = (None, 0) self._content_height_cache = (None, 0)
self.set_dirty()
self._repaint_required = True self._repaint_required = True
if isinstance(self.parent, Widget) and self.styles.auto_dimensions: if isinstance(self.parent, Widget) and self.styles.auto_dimensions:
self.parent.refresh(layout=True) self.parent.refresh(layout=True)

View File

@@ -2,7 +2,8 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from itertools import chain 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.console import RenderableType
from rich.padding import Padding from rich.padding import Padding
@@ -11,29 +12,48 @@ from rich.segment import Segment
from rich.style import Style from rich.style import Style
from rich.text import Text, TextType from rich.text import Text, TextType
from .. import events
from .._cache import LRUCache from .._cache import LRUCache
from .._segment_tools import line_crop from .._segment_tools import line_crop
from .._types import Lines from .._types import Lines
from ..geometry import Region, Size from ..geometry import clamp, Region, Size, Spacing
from ..reactive import Reactive from ..reactive import Reactive
from .._profile import timer from .._profile import timer
from ..scroll_view import ScrollView from ..scroll_view import ScrollView
from ..widget import Widget from ..widget import Widget
from .. import messages 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") CellType = TypeVar("CellType")
def default_cell_formatter(obj: object) -> RenderableType | None: 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): if isinstance(obj, str):
return Text.from_markup(obj) return Text.from_markup(obj)
if not is_renderable(obj): if not is_renderable(obj):
raise TypeError(f"Table cell {obj!r} is not renderable") return None
return cast(RenderableType, obj) return cast(RenderableType, obj)
@dataclass @dataclass
class Column: class Column:
"""Table column."""
label: Text label: Text
width: int width: int
visible: bool = False visible: bool = False
@@ -42,18 +62,46 @@ class Column:
@dataclass @dataclass
class Row: class Row:
"""Table row."""
index: int index: int
height: int height: int
y: int
cell_renderables: list[RenderableType] = field(default_factory=list) cell_renderables: list[RenderableType] = field(default_factory=list)
@dataclass @dataclass
class Cell: class Cell:
"""Table cell."""
value: object value: object
class Header(Widget): class Coord(NamedTuple):
pass """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]): class DataTable(ScrollView, Generic[CellType]):
@@ -82,13 +130,17 @@ class DataTable(ScrollView, Generic[CellType]):
background: $primary 10%; background: $primary 10%;
} }
DataTable > .datatable--cursor {
background: $secondary;
color: $text-secondary;
}
.-dark-mode DataTable > .datatable--even-row { .-dark-mode DataTable > .datatable--even-row {
background: $primary 15%; background: $primary 15%;
} }
DataTable > .datatable--highlight { DataTable > .datatable--highlight {
background: $secondary; background: $secondary 20%;
color: $text-secondary;
} }
""" """
@@ -98,6 +150,7 @@ class DataTable(ScrollView, Generic[CellType]):
"datatable--odd-row", "datatable--odd-row",
"datatable--even-row", "datatable--even-row",
"datatable--highlight", "datatable--highlight",
"datatable--cursor",
} }
def __init__( def __init__(
@@ -114,20 +167,47 @@ class DataTable(ScrollView, Generic[CellType]):
self._y_offsets: list[tuple[int, int]] = [] 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._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._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_cache = LRUCache(1000)
self._line_no = 0
show_header = Reactive(True) show_header = Reactive(True)
fixed_rows = Reactive(0) fixed_rows = Reactive(0)
fixed_columns = Reactive(1) fixed_columns = Reactive(0)
zebra_stripes = Reactive(False) zebra_stripes = Reactive(False)
header_height = Reactive(1) 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: def _clear_caches(self) -> None:
self._row_render_cache.clear() self._row_render_cache.clear()
@@ -141,6 +221,7 @@ class DataTable(ScrollView, Generic[CellType]):
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None: async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
self._clear_caches() self._clear_caches()
self.refresh()
def watch_show_header(self, show_header: bool) -> None: def watch_show_header(self, show_header: bool) -> None:
self._clear_caches() self._clear_caches()
@@ -151,6 +232,20 @@ class DataTable(ScrollView, Generic[CellType]):
def watch_zebra_stripes(self, zebra_stripes: bool) -> None: def watch_zebra_stripes(self, zebra_stripes: bool) -> None:
self._clear_caches() 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: def _update_dimensions(self) -> None:
"""Called to recalculate the virtual (scrollable) size.""" """Called to recalculate the virtual (scrollable) size."""
total_width = sum(column.width for column in self.columns) 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), 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: def add_column(self, label: TextType, *, width: int = 10) -> None:
"""Add a column to the table. """Add a column to the table.
@@ -179,15 +286,25 @@ class DataTable(ScrollView, Generic[CellType]):
""" """
row_index = self.row_count row_index = self.row_count
self.data[row_index] = list(cells) 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): for line_no in range(height):
self._y_offsets.append((row_index, line_no)) self._y_offsets.append((row_index, line_no))
self.row_count += 1 self.row_count += 1
self._line_no += height
self._update_dimensions() self._update_dimensions()
self.refresh() 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]: def _get_row_renderables(self, row_index: int) -> list[RenderableType]:
"""Get renderables for the given row. """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] return [default_cell_formatter(datum) or empty for datum in data]
def _render_cell( 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: ) -> Lines:
"""Render the given cell. """Render the given cell.
@@ -223,7 +346,11 @@ class DataTable(ScrollView, Generic[CellType]):
Returns: Returns:
Lines: A list of segments per line. 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: if cell_key not in self._cell_render_cache:
style += Style.from_meta({"row": row_index, "column": column_index}) style += Style.from_meta({"row": row_index, "column": column_index})
height = ( height = (
@@ -239,7 +366,12 @@ class DataTable(ScrollView, Generic[CellType]):
return self._cell_render_cache[cell_key] return self._cell_render_cache[cell_key]
def _render_row( 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]: ) -> tuple[Lines, Lines]:
"""Render a row in to lines for each cell. """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. 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: if cache_key in self._row_render_cache:
return self._row_render_cache[cache_key] return self._row_render_cache[cache_key]
@@ -281,7 +413,14 @@ class DataTable(ScrollView, Generic[CellType]):
row_style = base_style row_style = base_style
scrollable_row = [ 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 for column in self.columns
] ]
@@ -302,6 +441,8 @@ class DataTable(ScrollView, Generic[CellType]):
if y < self.header_height: if y < self.header_height:
return (-1, y) return (-1, y)
y -= self.header_height 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] return self._y_offsets[y]
def _render_line( def _render_line(
@@ -319,34 +460,42 @@ class DataTable(ScrollView, Generic[CellType]):
list[Segment]: List of segments for rendering. 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: if cache_key in self._line_cache:
return self._line_cache[cache_key] return self._line_cache[cache_key]
row_index, line_no = self._get_offsets(y) fixed, scrollable = self._render_row(
row_index,
fixed, scrollable = self._render_row(row_index, line_no, base_style) 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_width = sum(column.width for column in self.columns[: self.fixed_columns])
fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else [] fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else []
scrollable_line: list[Segment] = list(chain.from_iterable(scrollable)) scrollable_line: list[Segment] = list(chain.from_iterable(scrollable))
segments = fixed_line + line_crop(scrollable_line, x1 + fixed_width, x2, width) 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)) simplified_segments = list(Segment.simplify(segments))
self._line_cache[cache_key] = simplified_segments self._line_cache[cache_key] = simplified_segments
return segments return segments
@timer("render_lines")
def render_lines(self, crop: Region) -> Lines: def render_lines(self, crop: Region) -> Lines:
"""Render lines within a given region. """Render lines within a given region.
@@ -356,9 +505,8 @@ class DataTable(ScrollView, Generic[CellType]):
Returns: Returns:
Lines: A list of segments for every line within crop region. Lines: A list of segments for every line within crop region.
""" """
scroll_y = self.scroll_offset.y
scroll_x, scroll_y = self.scroll_offset x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners
x1, y1, x2, y2 = crop.translate(scroll_x, scroll_y).corners
base_style = self.rich_style base_style = self.rich_style
@@ -380,5 +528,58 @@ class DataTable(ScrollView, Generic[CellType]):
return lines return lines
def on_mouse_move(self, event): def on_mouse_move(self, event: events.MouseMove):
print(self.get_style_at(event.x, event.y).meta) 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" 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(): def test_offset_mul():
assert Offset(2, 1) * 2 == Offset(4, 2) assert Offset(2, 1) * 2 == Offset(4, 2)
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) assert Region.from_union(regions) == Region(10, 20, 40, 40)
def test_region_from_origin(): def test_region_from_offset():
assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6) 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(): def test_region_area():
@@ -140,7 +158,7 @@ def test_region_size():
def test_region_origin(): 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(): def test_region_bottom_left():
@@ -167,6 +185,16 @@ def test_region_sub():
Region(1, 2, 3, 4) - "foo" 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(): def test_region_overlaps():
assert Region(10, 10, 30, 20).overlaps(Region(0, 0, 20, 20)) 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)) 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(): 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((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((0, 20)) == Region(1, 22, 3, 4)
def test_region_contains_special(): def test_region_contains_special():
@@ -259,11 +287,11 @@ def test_region_y_extents():
def test_region_x_max(): 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(): 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(): 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) assert Region(5, 10, 20, 30).y_range == range(10, 40)
def test_region_reset_origin(): def test_region_reset_offset():
assert Region(5, 10, 20, 30).reset_origin == Region(0, 0, 20, 30) assert Region(5, 10, 20, 30).reset_offset == Region(0, 0, 20, 30)
def test_region_expand(): def test_region_expand():