mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
fix lock
This commit is contained in:
6
.github/workflows/pythonpackage.yml
vendored
6
.github/workflows/pythonpackage.yml
vendored
@@ -43,3 +43,9 @@ jobs:
|
||||
run: |
|
||||
source $VENV
|
||||
python e2e_tests/sandbox_basic_test.py basic 2.0
|
||||
- name: Upload snapshot report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: snapshot-report-textual
|
||||
path: tests/snapshot_tests/output/snapshot_report.html
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -113,3 +113,6 @@ venv.bak/
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
|
||||
# Snapshot testing report output directory
|
||||
tests/snapshot_tests/output
|
||||
|
||||
@@ -13,3 +13,4 @@ repos:
|
||||
hooks:
|
||||
- id: black
|
||||
exclude: ^tests/
|
||||
exclude: ^tests/snapshot_tests
|
||||
|
||||
3
Makefile
3
Makefile
@@ -2,6 +2,8 @@ test:
|
||||
pytest --cov-report term-missing --cov=textual tests/ -vv
|
||||
unit-test:
|
||||
pytest --cov-report term-missing --cov=textual tests/ -vv -m "not integration_test"
|
||||
test-snapshot-update:
|
||||
pytest --cov-report term-missing --cov=textual tests/ -vv --snapshot-update
|
||||
typecheck:
|
||||
mypy src/textual
|
||||
format:
|
||||
@@ -14,4 +16,3 @@ docs-build:
|
||||
mkdocs build
|
||||
docs-deploy:
|
||||
mkdocs gh-deploy
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
|
||||
43
notes/snapshot_testing.md
Normal file
43
notes/snapshot_testing.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Snapshot Testing
|
||||
|
||||
|
||||
## What is snapshot testing?
|
||||
|
||||
Some tests that run for Textual are snapshot tests.
|
||||
When you first run a snapshot test, a screenshot of an app is taken and saved to disk.
|
||||
Next time you run it, another screenshot is taken and compared with the original one.
|
||||
|
||||
If the screenshots don't match, it means something has changed.
|
||||
It's up to you to tell the test system whether that change is expected or not.
|
||||
|
||||
This allows us to easily catch regressions in how Textual outputs to the terminal.
|
||||
|
||||
Snapshot tests run alongside normal unit tests.
|
||||
|
||||
## How do I write a snapshot test?
|
||||
|
||||
1. Inject the `snap_compare` fixture into your test.
|
||||
2. Pass in the path to the file which contains the Textual app.
|
||||
|
||||
```python
|
||||
def test_grid_layout_basic_overflow(snap_compare):
|
||||
assert snap_compare("docs/examples/guide/layout/grid_layout2.py")
|
||||
```
|
||||
|
||||
`snap_compare` can take additional arguments such as `press`, which allows
|
||||
you to simulate key presses etc.
|
||||
See the signature of `snap_compare` for more info.
|
||||
|
||||
## A snapshot test failed, what do I do?
|
||||
|
||||
When a snapshot test fails, a report will be created on your machine, and you
|
||||
can use this report to visually compare the output from your test with the historical output for that test.
|
||||
|
||||
This report will be visible at the bottom of the terminal after the `pytest` session completes,
|
||||
or, if running in CI, it will be available as an artifact attached to the GitHub Actions summary.
|
||||
|
||||
If you're happy that the new output of the app is correct, you can run `pytest` with the
|
||||
`--snapshot-update` flag. This flag will update the snapshots for any test that is executed in the run,
|
||||
so to update a snapshot for a single test, run only that test.
|
||||
|
||||
With your snapshot on disk updated to match the new output, running the test again should result in a pass.
|
||||
117
poetry.lock
generated
117
poetry.lock
generated
@@ -1,6 +1,6 @@
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.8.2"
|
||||
version = "3.8.3"
|
||||
description = "Async http client/server framework (asyncio)"
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -13,7 +13,7 @@ asynctest = {version = "0.13.0", markers = "python_version < \"3.8\""}
|
||||
attrs = ">=17.3.0"
|
||||
charset-normalizer = ">=2.0,<3.0"
|
||||
frozenlist = ">=1.1.1"
|
||||
multidict = ">=4.5,<6.0"
|
||||
multidict = ">=4.5,<7.0"
|
||||
typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
|
||||
yarl = ">=1.0,<2.0"
|
||||
|
||||
@@ -50,14 +50,6 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "atomicwrites"
|
||||
version = "1.4.1"
|
||||
description = "Atomic file writes."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "22.1.0"
|
||||
@@ -150,6 +142,14 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "1.4.3"
|
||||
description = "Simple library for color and formatting to terminal"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "commonmark"
|
||||
version = "0.9.1"
|
||||
@@ -420,11 +420,11 @@ python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "multidict"
|
||||
version = "5.2.0"
|
||||
version = "6.0.2"
|
||||
description = "multidict implementation"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
@@ -575,14 +575,13 @@ diagrams = ["railroad-diagrams", "jinja2"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "6.2.5"
|
||||
version = "7.1.3"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
|
||||
attrs = ">=19.2.0"
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
|
||||
@@ -590,10 +589,10 @@ iniconfig = "*"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<2.0"
|
||||
py = ">=1.8.2"
|
||||
toml = "*"
|
||||
tomli = ">=1.0.0"
|
||||
|
||||
[package.extras]
|
||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
|
||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-aiohttp"
|
||||
@@ -719,6 +718,18 @@ category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
|
||||
[[package]]
|
||||
name = "syrupy"
|
||||
version = "3.0.0"
|
||||
description = "PyTest Snapshot Test Utility"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7,<4"
|
||||
|
||||
[package.dependencies]
|
||||
colored = ">=1.3.92,<2.0.0"
|
||||
pytest = ">=5.1.0,<8.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "time-machine"
|
||||
version = "2.8.1"
|
||||
@@ -835,7 +846,7 @@ dev = ["aiohttp", "click", "msgpack"]
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.7"
|
||||
content-hash = "b90d9035b77cd2b09d3a563df9d2ea31ad673564ed12d998422d7cae4b40661b"
|
||||
content-hash = "7c5f4afc8cc0d0be1fa26c2db1f48c28226f3fcc33e3938b4d2d57f73d17e74c"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = []
|
||||
@@ -851,7 +862,6 @@ asynctest = [
|
||||
{file = "asynctest-0.13.0-py3-none-any.whl", hash = "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676"},
|
||||
{file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"},
|
||||
]
|
||||
atomicwrites = []
|
||||
attrs = []
|
||||
black = []
|
||||
cached-property = [
|
||||
@@ -872,6 +882,7 @@ colorama = [
|
||||
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
|
||||
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
|
||||
]
|
||||
colored = []
|
||||
commonmark = [
|
||||
{file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
|
||||
{file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
|
||||
@@ -1021,7 +1032,67 @@ msgpack = [
|
||||
{file = "msgpack-1.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:4d5834a2a48965a349da1c5a79760d94a1a0172fbb5ab6b5b33cbf8447e109ce"},
|
||||
{file = "msgpack-1.0.4.tar.gz", hash = "sha256:f5d869c18f030202eb412f08b28d2afeea553d6613aee89e200d7aca7ef01f5f"},
|
||||
]
|
||||
multidict = []
|
||||
multidict = [
|
||||
{file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"},
|
||||
{file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
|
||||
]
|
||||
mypy = [
|
||||
{file = "mypy-0.950-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf9c261958a769a3bd38c3e133801ebcd284ffb734ea12d01457cb09eacf7d7b"},
|
||||
{file = "mypy-0.950-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5b5bd0ffb11b4aba2bb6d31b8643902c48f990cc92fda4e21afac658044f0c0"},
|
||||
@@ -1083,10 +1154,7 @@ pyparsing = [
|
||||
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
|
||||
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
|
||||
]
|
||||
pytest = [
|
||||
{file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"},
|
||||
{file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"},
|
||||
]
|
||||
pytest = []
|
||||
pytest-aiohttp = [
|
||||
{file = "pytest-aiohttp-1.0.4.tar.gz", hash = "sha256:39ff3a0d15484c01d1436cbedad575c6eafbf0f57cdf76fb94994c97b5b8c5a4"},
|
||||
{file = "pytest_aiohttp-1.0.4-py3-none-any.whl", hash = "sha256:1d2dc3a304c2be1fd496c0c2fb6b31ab60cd9fc33984f761f951f8ea1eb4ca95"},
|
||||
@@ -1145,6 +1213,7 @@ six = [
|
||||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
||||
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
||||
]
|
||||
syrupy = []
|
||||
time-machine = []
|
||||
toml = [
|
||||
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
|
||||
|
||||
@@ -37,7 +37,7 @@ nanoid = "^2.0.0"
|
||||
dev = ["aiohttp", "click", "msgpack"]
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^6.2.3"
|
||||
pytest = "^7.1.3"
|
||||
black = "^22.3.0"
|
||||
mypy = "^0.950"
|
||||
pytest-cov = "^2.12.1"
|
||||
@@ -48,6 +48,7 @@ pre-commit = "^2.13.0"
|
||||
pytest-aiohttp = "^1.0.4"
|
||||
time-machine = "^2.6.0"
|
||||
Jinja2 = "<3.1.0"
|
||||
syrupy = "^3.0.0"
|
||||
|
||||
[tool.black]
|
||||
includes = "src"
|
||||
@@ -55,6 +56,7 @@ includes = "src"
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
addopts = "--strict-markers"
|
||||
markers = [
|
||||
"integration_test: marks tests as slow integration tests (deselect with '-m \"not integration_test\"')",
|
||||
]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Dev Sandbox
|
||||
|
||||
This directory contains test code for Textual devs to experiment with new features. None of the .py files here are guaranteed to run or do anything useful, but you are welcome to look around.
|
||||
@@ -1,48 +0,0 @@
|
||||
|
||||
|
||||
Screen {
|
||||
layout: vertical;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
Widget {
|
||||
margin:1;
|
||||
}
|
||||
|
||||
#thing {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background:magenta;
|
||||
margin: 1;
|
||||
padding: 1;
|
||||
border: solid white;
|
||||
box-sizing: border-box;
|
||||
border: solid white;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
|
||||
#thing2 {
|
||||
border: solid white;
|
||||
/* outline: heavy blue; */
|
||||
height: 10;
|
||||
padding: 1 2;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
max-height: 100vh;
|
||||
|
||||
background:green;
|
||||
align-horizontal: center;
|
||||
color:white;
|
||||
}
|
||||
|
||||
|
||||
#thing3 {
|
||||
height: 10;
|
||||
margin: 1;
|
||||
background:blue;
|
||||
color: white 50%;
|
||||
border: white;
|
||||
align-horizontal: center;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
from rich.style import Style
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class Thing(Widget):
|
||||
def render(self):
|
||||
return "Hello, 3434 World.\n[b]Lorem impsum."
|
||||
|
||||
|
||||
class AlignApp(App):
|
||||
CSS_PATH = "align.css"
|
||||
|
||||
def on_load(self):
|
||||
self.bind("t", "log_tree")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Thing(id="thing")
|
||||
yield Static("foo", id="thing2")
|
||||
yield Widget(id="thing3")
|
||||
|
||||
def action_log_tree(self):
|
||||
self.log(self.screen.tree)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = AlignApp(css_path="align.css")
|
||||
app.run()
|
||||
@@ -1,16 +0,0 @@
|
||||
Vertical {
|
||||
background: red 50%;
|
||||
}
|
||||
|
||||
.test {
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
background: white 50%;
|
||||
border:solid green;
|
||||
padding: 0;
|
||||
margin:3;
|
||||
|
||||
align: center middle;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
from textual.layout import Vertical
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
TEXT = Text.from_markup(" ".join(str(n) * 5 for n in range(12)))
|
||||
|
||||
|
||||
class AutoApp(App):
|
||||
CSS_PATH = "auto_test.css"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Vertical(
|
||||
Static(TEXT, classes="test"), Static(TEXT, id="test", classes="test")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = AutoApp()
|
||||
app.run()
|
||||
@@ -1,66 +0,0 @@
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.css.types import EdgeType
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class VerticalContainer(Widget):
|
||||
DEFAULT_CSS = """
|
||||
VerticalContainer {
|
||||
layout: vertical;
|
||||
overflow: hidden auto;
|
||||
background: darkblue;
|
||||
}
|
||||
|
||||
VerticalContainer Placeholder {
|
||||
margin: 1 0;
|
||||
height: auto;
|
||||
align: center top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Introduction(Widget):
|
||||
DEFAULT_CSS = """
|
||||
Introduction {
|
||||
background: indigo;
|
||||
color: white;
|
||||
height: 3;
|
||||
padding: 1 0;
|
||||
}
|
||||
"""
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return Text("Here are the color edge types we support.", justify="center")
|
||||
|
||||
|
||||
class BorderDemo(Widget):
|
||||
def __init__(self, name: str):
|
||||
super().__init__(name=name)
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return Text(self.name, style="black on yellow", justify="center")
|
||||
|
||||
|
||||
class MyTestApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
border_demo_widgets = []
|
||||
for border_edge_type in EdgeType.__args__:
|
||||
border_demo = BorderDemo(f'"border: {border_edge_type} white"')
|
||||
border_demo.styles.height = "auto"
|
||||
border_demo.styles.margin = (1, 0)
|
||||
border_demo.styles.border = (border_edge_type, "white")
|
||||
border_demo_widgets.append(border_demo)
|
||||
|
||||
yield VerticalContainer(Introduction(), *border_demo_widgets, id="root")
|
||||
|
||||
def on_mount(self):
|
||||
self.bind("q", "quit")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = MyTestApp()
|
||||
app.run()
|
||||
@@ -1,6 +0,0 @@
|
||||
|
||||
Button {
|
||||
box-sizing: border-box;
|
||||
margin: 1;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
from textual import layout, events
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Button
|
||||
|
||||
|
||||
class ButtonsApp(App[str]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield layout.Vertical(
|
||||
Button("default", id="foo"),
|
||||
Button.success("success", id="bar"),
|
||||
Button.warning("warning", id="baz"),
|
||||
Button.error("error", id="baz"),
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
self.app.bell()
|
||||
|
||||
async def on_key(self, event: events.Key) -> None:
|
||||
await self.dispatch_key(event)
|
||||
|
||||
def key_d(self):
|
||||
self.dark = not self.dark
|
||||
|
||||
|
||||
app = ButtonsApp(
|
||||
log_path="textual.log",
|
||||
css_path="buttons.css",
|
||||
watch_css=True,
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = app.run()
|
||||
print(repr(result))
|
||||
@@ -1,46 +0,0 @@
|
||||
import rich.repr
|
||||
from rich.align import Align
|
||||
from rich.console import RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.pretty import Pretty
|
||||
|
||||
from textual._color_constants import COLOR_NAME_TO_RGB
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
@rich.repr.auto(angular=False)
|
||||
class ColorDisplay(Widget, can_focus=True):
|
||||
def render(self) -> RenderableType:
|
||||
return Panel(
|
||||
Align.center(
|
||||
Pretty(self, no_wrap=True, overflow="ellipsis"), vertical="middle"
|
||||
),
|
||||
title=self.name,
|
||||
border_style="none",
|
||||
)
|
||||
|
||||
|
||||
class ColorNames(App):
|
||||
DEFAULT_CSS = """
|
||||
ColorDisplay {
|
||||
height: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def on_mount(self):
|
||||
self.bind("q", "quit")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
for color_name, color in COLOR_NAME_TO_RGB.items():
|
||||
color_placeholder = ColorDisplay(name=color_name)
|
||||
is_dark_color = sum(color) < 400
|
||||
color_placeholder.styles.color = "white" if is_dark_color else "black"
|
||||
color_placeholder.styles.background = color_name
|
||||
yield color_placeholder
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
color_name_app = ColorNames()
|
||||
color_name_app.run()
|
||||
@@ -1,54 +0,0 @@
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class FocusKeybindsApp(App):
|
||||
dark = True
|
||||
|
||||
def on_load(self) -> None:
|
||||
self.bind("1", "focus('widget1')")
|
||||
self.bind("2", "focus('widget2')")
|
||||
self.bind("3", "focus('widget3')")
|
||||
self.bind("4", "focus('widget4')")
|
||||
self.bind("q", "focus('widgetq')")
|
||||
self.bind("w", "focus('widgetw')")
|
||||
self.bind("e", "focus('widgete')")
|
||||
self.bind("r", "focus('widgetr')")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
info = Static(
|
||||
"Use keybinds to shift focus between the widgets in the lists below",
|
||||
)
|
||||
self.mount(info=info)
|
||||
|
||||
self.mount(
|
||||
body=Widget(
|
||||
Widget(
|
||||
Static("Press 1 to focus", id="widget1", classes="list-item"),
|
||||
Static("Press 2 to focus", id="widget2", classes="list-item"),
|
||||
Static("Press 3 to focus", id="widget3", classes="list-item"),
|
||||
Static("Press 4 to focus", id="widget4", classes="list-item"),
|
||||
classes="list",
|
||||
id="left_list",
|
||||
),
|
||||
Widget(
|
||||
Static("Press Q to focus", id="widgetq", classes="list-item"),
|
||||
Static("Press W to focus", id="widgetw", classes="list-item"),
|
||||
Static("Press E to focus", id="widgete", classes="list-item"),
|
||||
Static("Press R to focus", id="widgetr", classes="list-item"),
|
||||
classes="list",
|
||||
id="right_list",
|
||||
),
|
||||
),
|
||||
)
|
||||
self.mount(footer=Static("No widget focused"))
|
||||
|
||||
def on_descendant_focus(self):
|
||||
self.get_child("footer").update(
|
||||
f"Focused: {self.focused.id}" or "No widget focused"
|
||||
)
|
||||
|
||||
|
||||
app = FocusKeybindsApp(css_path="focus_keybindings.scss", watch_css=True)
|
||||
app.run()
|
||||
@@ -1,57 +0,0 @@
|
||||
App > Screen {
|
||||
layout: dock;
|
||||
docks: left=left top=top;
|
||||
}
|
||||
|
||||
#info {
|
||||
background: $primary;
|
||||
dock: top;
|
||||
height: 3;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#body {
|
||||
dock: top;
|
||||
layout: dock;
|
||||
docks: bodylhs=left;
|
||||
}
|
||||
|
||||
#left_list {
|
||||
dock: bodylhs;
|
||||
padding: 2;
|
||||
}
|
||||
|
||||
#right_list {
|
||||
dock: bodylhs;
|
||||
padding: 2;
|
||||
}
|
||||
|
||||
#footer {
|
||||
height: 1;
|
||||
background: $secondary;
|
||||
padding: 0 1;
|
||||
dock: top;
|
||||
}
|
||||
|
||||
.list {
|
||||
background: $surface;
|
||||
border-top: hkey $surface-darken-1;
|
||||
}
|
||||
|
||||
.list:focus-within {
|
||||
background: $primary-darken-1;
|
||||
outline-top: $accent-lighten-1;
|
||||
outline-bottom: $accent-lighten-1;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
background: $surface;
|
||||
height: auto;
|
||||
border: $surface-darken-1 tall;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
.list-item:focus {
|
||||
background: $surface-darken-1;
|
||||
outline: $accent tall;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
from rich.console import RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.style import Style
|
||||
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class PanelWidget(Widget):
|
||||
def render(self) -> RenderableType:
|
||||
return Panel("hello world!", title="Title")
|
||||
|
||||
|
||||
class BasicApp(App):
|
||||
"""Sandbox application used for testing/development by Textual developers"""
|
||||
|
||||
def on_load(self):
|
||||
"""Bind keys here."""
|
||||
self.bind("tab", "toggle_class('#sidebar', '-active')")
|
||||
self.bind("a", "toggle_class('#header', '-visible')")
|
||||
self.bind("c", "toggle_class('#content', '-content-visible')")
|
||||
self.bind("d", "toggle_class('#footer', 'dim')")
|
||||
self.bind("x", "dump")
|
||||
|
||||
def on_mount(self):
|
||||
"""Build layout here."""
|
||||
self.mount(
|
||||
header=Widget(),
|
||||
content=PanelWidget(),
|
||||
footer=Widget(),
|
||||
sidebar=Widget(),
|
||||
)
|
||||
|
||||
def action_dump(self):
|
||||
self.panic(self.tree)
|
||||
|
||||
|
||||
BasicApp.run(css_path="dev_sandbox.scss", watch_css=True, log_path="textual.log")
|
||||
@@ -1,66 +0,0 @@
|
||||
/* CSS file for dev_sandbox.py */
|
||||
|
||||
$text: #f0f0f0;
|
||||
$primary: #021720;
|
||||
$secondary: #95d52a;
|
||||
$background: #262626;
|
||||
|
||||
$animatitext-speed: 500ms;
|
||||
$animation: offset $animatitext-speed in_out_cubic;
|
||||
|
||||
App > View {
|
||||
docks: side=left/1;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
Widget:hover {
|
||||
outline: heavy;
|
||||
text-style: bold !important;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
color: $text;
|
||||
background: $background;
|
||||
dock: side;
|
||||
width: 30;
|
||||
offset-x: -100%;
|
||||
transition: $animation;
|
||||
border-right: outer $secondary;
|
||||
}
|
||||
|
||||
#sidebar.-active {
|
||||
offset-x: 0;
|
||||
}
|
||||
|
||||
#header {
|
||||
color: $text;
|
||||
background: $primary;
|
||||
height: 3;
|
||||
border-bottom: hkey $secondary;
|
||||
}
|
||||
|
||||
#header.-visible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#content {
|
||||
color: $text;
|
||||
background: $background;
|
||||
offset-y: -3;
|
||||
}
|
||||
|
||||
#content.-content-visible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#footer {
|
||||
text-opacity: 1;
|
||||
color: $text;
|
||||
background: $background;
|
||||
height: 3;
|
||||
border-top: hkey $secondary;
|
||||
}
|
||||
|
||||
#footer.dim {
|
||||
text-opacity: 0.5;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
from textual.app import App
|
||||
from textual import layout
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class FiftyApp(App):
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
}
|
||||
Horizontal {
|
||||
height: 50%;
|
||||
}
|
||||
Widget {
|
||||
width: 50%;
|
||||
outline: white;
|
||||
background: blue;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
yield layout.Horizontal(Widget(), Widget())
|
||||
yield layout.Horizontal(Widget(), Widget())
|
||||
|
||||
|
||||
app = FiftyApp()
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -1,18 +0,0 @@
|
||||
Horizontal {
|
||||
background: red 50%;
|
||||
overflow-x: auto;
|
||||
/* width: auto */
|
||||
}
|
||||
|
||||
.test {
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
||||
background: white 50%;
|
||||
border:solid green;
|
||||
padding: 0;
|
||||
margin:3;
|
||||
|
||||
align: center middle;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
from textual import layout
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
TEXT = Text.from_markup(" ".join(str(n) * 5 for n in range(10)))
|
||||
|
||||
|
||||
class AutoApp(App):
|
||||
def on_mount(self) -> None:
|
||||
self.bind("t", "tree")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield layout.Horizontal(
|
||||
Static(TEXT, classes="test"), Static(TEXT, id="test", classes="test")
|
||||
)
|
||||
|
||||
def action_tree(self):
|
||||
self.log(self.screen.tree)
|
||||
|
||||
|
||||
app = AutoApp(css_path="horizontal.css")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -1,67 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
|
||||
from textual.widgets.text_input import TextInput, TextWidgetBase, TextArea
|
||||
|
||||
|
||||
def celsius_to_fahrenheit(celsius: float) -> float:
|
||||
return celsius * 1.8 + 32
|
||||
|
||||
|
||||
def fahrenheit_to_celsius(fahrenheit: float) -> float:
|
||||
return (fahrenheit - 32) / 1.8
|
||||
|
||||
|
||||
words = set(Path("/usr/share/dict/words").read_text().splitlines())
|
||||
|
||||
|
||||
def word_autocompleter(value: str) -> str | None:
|
||||
# An example autocompleter that uses the Unix dictionary to suggest
|
||||
# word completions
|
||||
for word in words:
|
||||
if word.startswith(value):
|
||||
return word
|
||||
return None
|
||||
|
||||
|
||||
class InputApp(App[str]):
|
||||
def on_mount(self) -> None:
|
||||
self.fahrenheit = TextInput(placeholder="Fahrenheit", id="fahrenheit")
|
||||
self.celsius = TextInput(placeholder="Celsius", id="celsius")
|
||||
self.fahrenheit.focus()
|
||||
text_boxes = Widget(self.fahrenheit, self.celsius)
|
||||
self.mount(inputs=text_boxes)
|
||||
self.mount(spacer=Widget())
|
||||
self.mount(
|
||||
top_search=Widget(
|
||||
TextInput(autocompleter=word_autocompleter, id="topsearchbox")
|
||||
)
|
||||
)
|
||||
self.mount(
|
||||
footer=TextInput(
|
||||
placeholder="Footer Search Bar", autocompleter=word_autocompleter
|
||||
)
|
||||
)
|
||||
self.mount(text_area=TextArea())
|
||||
|
||||
def on_text_input_changed_changed(self, event: TextInput.Changed) -> None:
|
||||
try:
|
||||
value = float(event.value)
|
||||
except ValueError:
|
||||
return
|
||||
if event.sender == self.celsius:
|
||||
fahrenheit = celsius_to_fahrenheit(value)
|
||||
self.fahrenheit.value = f"{fahrenheit:.1f}"
|
||||
elif event.sender == self.fahrenheit:
|
||||
celsius = fahrenheit_to_celsius(value)
|
||||
self.celsius.value = f"{celsius:.1f}"
|
||||
|
||||
|
||||
app = InputApp(log_path="textual.log", css_path="input.scss", watch_css=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = app.run()
|
||||
@@ -1,53 +0,0 @@
|
||||
App {
|
||||
background: $secondary;
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 1;
|
||||
background: $primary-darken-2;
|
||||
dock: top;
|
||||
}
|
||||
|
||||
Screen {
|
||||
layout: dock;
|
||||
docks: top=top bottom=bottom;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
#fahrenheit {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
#celsius {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
#celsius :focus {
|
||||
border: heavy darkgoldenrod;
|
||||
}
|
||||
|
||||
#inputs {
|
||||
dock: top;
|
||||
background: $primary;
|
||||
height: 3;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#text_area {
|
||||
dock: bottom;
|
||||
}
|
||||
|
||||
#top_search {
|
||||
dock: top;
|
||||
}
|
||||
|
||||
#topsearchbox {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
#footer {
|
||||
background: $primary-darken-2;
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
border: ;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
App > View {
|
||||
layout: dock;
|
||||
}
|
||||
|
||||
Widget {
|
||||
text: on blue;
|
||||
}
|
||||
|
||||
Widget.-highlight {
|
||||
outline: heavy red;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class BasicApp(App):
|
||||
"""Sandbox application used for testing/development by Textual developers"""
|
||||
|
||||
def on_mount(self):
|
||||
"""Build layout here."""
|
||||
self.mount(
|
||||
header=Widget(),
|
||||
content=Placeholder(),
|
||||
footer=Widget(),
|
||||
sidebar=Widget(),
|
||||
)
|
||||
|
||||
async def on_key(self, event: events.Key) -> None:
|
||||
await self.dispatch_key(event)
|
||||
|
||||
def key_a(self) -> None:
|
||||
footer = self.get_child("footer")
|
||||
footer.set_styles(text="on magenta")
|
||||
|
||||
def key_b(self) -> None:
|
||||
footer = self.get_child("footer")
|
||||
footer.set_styles("text: on green")
|
||||
|
||||
def key_c(self) -> None:
|
||||
header = self.get_child("header")
|
||||
header.toggle_class("-highlight")
|
||||
self.log(header.styles)
|
||||
|
||||
|
||||
BasicApp.run(css_path="local_styles.css", log_path="textual.log")
|
||||
@@ -1,25 +0,0 @@
|
||||
|
||||
|
||||
Vertical {
|
||||
background: blue;
|
||||
|
||||
}
|
||||
|
||||
#container {
|
||||
width:50%;
|
||||
height: auto;
|
||||
align-horizontal: center;
|
||||
padding: 1;
|
||||
border: heavy white;
|
||||
background: white 50%;
|
||||
overflow-y: auto
|
||||
}
|
||||
|
||||
TextWidget {
|
||||
/* width: 50%; */
|
||||
height: auto;
|
||||
padding: 2;
|
||||
background: green 30%;
|
||||
border: yellow;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widget import Widget
|
||||
from textual import layout
|
||||
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
|
||||
|
||||
TEXT = Text.from_markup(lorem)
|
||||
|
||||
|
||||
class TextWidget(Widget):
|
||||
def render(self):
|
||||
return TEXT
|
||||
|
||||
|
||||
class AutoApp(App, css_path="nest.css"):
|
||||
def on_mount(self) -> None:
|
||||
self.bind("t", "tree")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield layout.Vertical(
|
||||
Widget(
|
||||
TextWidget(classes="test"),
|
||||
id="container",
|
||||
),
|
||||
)
|
||||
|
||||
def action_tree(self):
|
||||
self.log(self.screen.tree)
|
||||
@@ -1,72 +0,0 @@
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
placeholders_count = 12
|
||||
|
||||
|
||||
class VerticalContainer(Widget):
|
||||
DEFAULT_CSS = """
|
||||
VerticalContainer {
|
||||
layout: vertical;
|
||||
overflow: hidden auto;
|
||||
background: darkblue;
|
||||
}
|
||||
|
||||
VerticalContainer Placeholder {
|
||||
margin: 1 0;
|
||||
height: 5;
|
||||
border: solid lime;
|
||||
align: center top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Introduction(Widget):
|
||||
DEFAULT_CSS = """
|
||||
Introduction {
|
||||
background: indigo;
|
||||
color: white;
|
||||
height: 3;
|
||||
padding: 1 0;
|
||||
}
|
||||
"""
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return Text(
|
||||
"Press keys 0 to 9 to scroll to the Placeholder with that ID.",
|
||||
justify="center",
|
||||
)
|
||||
|
||||
|
||||
class MyTestApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
placeholders = [
|
||||
Placeholder(id=f"placeholder_{i}", name=f"Placeholder #{i}")
|
||||
for i in range(placeholders_count)
|
||||
]
|
||||
|
||||
yield VerticalContainer(Introduction(), *placeholders, id="root")
|
||||
|
||||
def on_mount(self):
|
||||
self.bind("q", "quit")
|
||||
self.bind("t", "tree")
|
||||
for widget_index in range(placeholders_count):
|
||||
self.bind(str(widget_index), f"scroll_to('placeholder_{widget_index}')")
|
||||
|
||||
def action_tree(self):
|
||||
self.log(self.tree)
|
||||
|
||||
async def action_scroll_to(self, target_placeholder_id: str):
|
||||
target_placeholder = self.query(f"#{target_placeholder_id}").first()
|
||||
target_placeholder_container = self.query("#root").first()
|
||||
target_placeholder_container.scroll_to_widget(target_placeholder, animate=True)
|
||||
|
||||
|
||||
app = MyTestApp()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -1,3 +0,0 @@
|
||||
from textual.app import App
|
||||
|
||||
app = App()
|
||||
@@ -1,28 +0,0 @@
|
||||
App.-show-focus *:focus {
|
||||
tint: #8bc34a 20%;
|
||||
}
|
||||
|
||||
#uber1 {
|
||||
layout: vertical;
|
||||
background: green;
|
||||
overflow: hidden auto;
|
||||
border: heavy white;
|
||||
text-style: underline;
|
||||
/* box-sizing: content-box; */
|
||||
}
|
||||
|
||||
#uber1:focus-within {
|
||||
background: darkslateblue;
|
||||
}
|
||||
|
||||
#child2 {
|
||||
text-style: underline;
|
||||
background: red 10%;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
height: 10;
|
||||
/* display: none; */
|
||||
color: #12a0;
|
||||
background: #ffffff00;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import random
|
||||
import sys
|
||||
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class BasicApp(App):
|
||||
"""Sandbox application used for testing/development by Textual developers"""
|
||||
|
||||
def on_load(self):
|
||||
self.bind("q", "quit", "Quit")
|
||||
self.bind("d", "dump")
|
||||
self.bind("t", "log_tree")
|
||||
self.bind("p", "print")
|
||||
self.bind("v", "toggle_visibility")
|
||||
self.bind("x", "toggle_display")
|
||||
self.bind("f", "modify_focussed")
|
||||
self.bind("b", "toggle_border")
|
||||
|
||||
async def on_mount(self):
|
||||
"""Build layout here."""
|
||||
first_child = Placeholder(id="child1", classes="list-item")
|
||||
uber1 = Widget(
|
||||
first_child,
|
||||
Placeholder(id="child2", classes="list-item"),
|
||||
Placeholder(id="child3", classes="list-item"),
|
||||
Placeholder(classes="list-item"),
|
||||
Placeholder(classes="list-item"),
|
||||
Placeholder(classes="list-item"),
|
||||
)
|
||||
self.mount(uber1=uber1)
|
||||
uber1.focus()
|
||||
self.first_child = first_child
|
||||
self.uber = uber1
|
||||
|
||||
async def on_key(self, event: events.Key) -> None:
|
||||
await self.dispatch_key(event)
|
||||
|
||||
def action_quit(self):
|
||||
self.panic(self.app.tree)
|
||||
|
||||
def action_dump(self):
|
||||
self.panic(str(self.app._registry))
|
||||
|
||||
def action_log_tree(self):
|
||||
self.log(self.screen.tree)
|
||||
|
||||
def action_print(self):
|
||||
print(
|
||||
"Focused widget is:",
|
||||
self.focused,
|
||||
)
|
||||
self.app.set_focus(None)
|
||||
|
||||
def action_modify_focussed(self):
|
||||
"""Increment height of focussed child, randomise border and bg color"""
|
||||
previous_height = self.focused.styles.height.value
|
||||
new_height = previous_height + 1
|
||||
self.focused.styles.height = self.focused.styles.height.copy_with(
|
||||
value=new_height
|
||||
)
|
||||
color = random.choice(["red", "green", "blue"])
|
||||
self.focused.styles.background = color
|
||||
self.focused.styles.border = ("dashed", color)
|
||||
|
||||
def action_toggle_visibility(self):
|
||||
self.focused.visible = not self.focused.visible
|
||||
|
||||
def action_toggle_display(self):
|
||||
# TODO: Doesn't work
|
||||
self.focused.display = not self.focused.display
|
||||
|
||||
def action_toggle_border(self):
|
||||
self.focused.styles.border_top = ("solid", "invalid-color")
|
||||
|
||||
|
||||
app = BasicApp(css_path="uber.css")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -1,22 +0,0 @@
|
||||
Screen {
|
||||
background:blue;
|
||||
}
|
||||
|
||||
Vertical {
|
||||
background: red 50%;
|
||||
overflow: auto;
|
||||
/* width: auto */
|
||||
}
|
||||
|
||||
.test {
|
||||
/* width: auto; */
|
||||
/* height: 50vh; */
|
||||
|
||||
background: white 50%;
|
||||
border:solid green;
|
||||
padding: 0;
|
||||
margin:3;
|
||||
|
||||
align: center middle;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
from textual import layout
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
TEXT = Text.from_markup(" ".join(str(n) * 5 for n in range(10)))
|
||||
|
||||
|
||||
class AutoApp(App):
|
||||
def on_mount(self) -> None:
|
||||
self.bind("t", "tree")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield layout.Horizontal(
|
||||
layout.Vertical(
|
||||
Static(TEXT, classes="test"),
|
||||
Static(TEXT, id="test", classes="test"),
|
||||
)
|
||||
)
|
||||
|
||||
def action_tree(self):
|
||||
self.log(self.screen.tree)
|
||||
|
||||
|
||||
app = AutoApp(css_path="vertical.css")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -1,94 +0,0 @@
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
root_container_style = "border: solid white;"
|
||||
initial_placeholders_count = 4
|
||||
|
||||
|
||||
class VerticalContainer(Widget):
|
||||
DEFAULT_CSS = """
|
||||
VerticalContainer {
|
||||
layout: vertical;
|
||||
overflow: hidden auto;
|
||||
background: darkblue;
|
||||
${root_container_style}
|
||||
}
|
||||
|
||||
VerticalContainer Placeholder {
|
||||
margin: 1 0;
|
||||
height: 5;
|
||||
border: solid lime;
|
||||
align: center top;
|
||||
}
|
||||
""".replace(
|
||||
"${root_container_style}", root_container_style
|
||||
)
|
||||
|
||||
|
||||
class Introduction(Widget):
|
||||
DEFAULT_CSS = """
|
||||
Introduction {
|
||||
background: indigo;
|
||||
color: white;
|
||||
height: 3;
|
||||
padding: 1 0;
|
||||
}
|
||||
"""
|
||||
|
||||
def render(self, styles) -> RenderableType:
|
||||
return Text(
|
||||
"Press '-' and '+' to add or remove placeholders.", justify="center"
|
||||
)
|
||||
|
||||
|
||||
class MyTestApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
# yield Introduction()
|
||||
|
||||
placeholders = [
|
||||
Placeholder(id=f"placeholder_{i}", name=f"Placeholder #{i}")
|
||||
for i in range(initial_placeholders_count)
|
||||
]
|
||||
|
||||
yield VerticalContainer(Introduction(), *placeholders, id="root")
|
||||
|
||||
def on_mount(self):
|
||||
self.bind("q", "quit")
|
||||
self.bind("t", "tree")
|
||||
self.bind("-", "remove_placeholder")
|
||||
self.bind("+", "add_placeholder")
|
||||
|
||||
def action_tree(self):
|
||||
self.log(self.tree)
|
||||
|
||||
async def action_remove_placeholder(self):
|
||||
placeholders = self.query("Placeholder")
|
||||
placeholders_count = len(placeholders)
|
||||
for i, placeholder in enumerate(placeholders):
|
||||
if i == placeholders_count - 1:
|
||||
await self.remove(placeholder)
|
||||
placeholder.parent.children._nodes.remove(placeholder)
|
||||
self.refresh(repaint=True, layout=True)
|
||||
self.refresh_css()
|
||||
|
||||
async def action_add_placeholder(self):
|
||||
placeholders = self.query("Placeholder")
|
||||
placeholders_count = len(placeholders)
|
||||
placeholder = Placeholder(
|
||||
id=f"placeholder_{placeholders_count}",
|
||||
name=f"Placeholder #{placeholders_count}",
|
||||
)
|
||||
root = self.get_child("root")
|
||||
root.mount(placeholder)
|
||||
self.refresh(repaint=True, layout=True)
|
||||
self.refresh_css()
|
||||
|
||||
|
||||
app = MyTestApp()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -98,7 +98,7 @@ def arrange(
|
||||
)
|
||||
|
||||
dock_spacing = Spacing(top, right, bottom, left)
|
||||
region = size.region.shrink(dock_spacing)
|
||||
region = region.shrink(dock_spacing)
|
||||
layout_placements, arranged_layout_widgets = widget._layout.arrange(
|
||||
widget, layout_widgets, region.size
|
||||
)
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import runpy
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import Iterable
|
||||
|
||||
from textual._import_app import AppFail, import_app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
from textual._import_app import import_app
|
||||
|
||||
|
||||
# This module defines our "Custom Fences", powered by SuperFences
|
||||
# @link https://facelessuser.github.io/pymdown-extensions/extensions/superfences/#custom-fences
|
||||
def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str:
|
||||
"""A superfences formatter to insert a SVG screenshot."""
|
||||
"""A superfences formatter to insert an SVG screenshot."""
|
||||
|
||||
try:
|
||||
cmd: list[str] = shlex.split(attrs["path"])
|
||||
@@ -23,22 +21,15 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str
|
||||
press = [*_press.split(",")] if _press else ["_"]
|
||||
title = attrs.get("title")
|
||||
|
||||
os.environ["COLUMNS"] = attrs.get("columns", "80")
|
||||
os.environ["LINES"] = attrs.get("lines", "24")
|
||||
|
||||
print(f"screenshotting {path!r}")
|
||||
|
||||
cwd = os.getcwd()
|
||||
try:
|
||||
app = import_app(path)
|
||||
app.run(
|
||||
quit_after=5,
|
||||
press=press or ["ctrl+c"],
|
||||
headless=True,
|
||||
screenshot=True,
|
||||
screenshot_title=title,
|
||||
rows = int(attrs.get("lines", 24))
|
||||
columns = int(attrs.get("columns", 80))
|
||||
svg = take_svg_screenshot(
|
||||
None, path, press, title, terminal_size=(rows, columns)
|
||||
)
|
||||
svg = app._screenshot
|
||||
finally:
|
||||
os.chdir(cwd)
|
||||
|
||||
@@ -51,8 +42,51 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str
|
||||
traceback.print_exception(error)
|
||||
|
||||
|
||||
def take_svg_screenshot(
|
||||
app: App | None = None,
|
||||
app_path: str | None = None,
|
||||
press: Iterable[str] = ("_",),
|
||||
title: str | None = None,
|
||||
terminal_size: tuple[int, int] = (24, 80),
|
||||
) -> str:
|
||||
"""
|
||||
|
||||
Args:
|
||||
app: An app instance. Must be supplied if app_path is not.
|
||||
app_path: A path to an app. Must be supplied if app is not.
|
||||
press: Key presses to run before taking screenshot. "_" is a short pause.
|
||||
title: The terminal title in the output image.
|
||||
terminal_size: A pair of integers (rows, columns), representing terminal size.
|
||||
|
||||
Returns:
|
||||
str: An SVG string, showing the content of the terminal window at the time
|
||||
the screenshot was taken.
|
||||
|
||||
"""
|
||||
rows, columns = terminal_size
|
||||
|
||||
os.environ["COLUMNS"] = str(columns)
|
||||
os.environ["LINES"] = str(rows)
|
||||
|
||||
if app is None:
|
||||
app = import_app(app_path)
|
||||
|
||||
if title is None:
|
||||
title = app.title
|
||||
|
||||
app.run(
|
||||
quit_after=5,
|
||||
press=press or ["ctrl+c"],
|
||||
headless=True,
|
||||
screenshot=True,
|
||||
screenshot_title=title,
|
||||
)
|
||||
svg = app._screenshot
|
||||
return svg
|
||||
|
||||
|
||||
def rich(source, language, css_class, options, md, attrs, **kwargs) -> str:
|
||||
"""A superfences formatter to insert a SVG screenshot."""
|
||||
"""A superfences formatter to insert an SVG screenshot."""
|
||||
|
||||
import io
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
markup=False,
|
||||
highlight=False,
|
||||
emoji=False,
|
||||
legacy_windows=False,
|
||||
)
|
||||
self.error_console = Console(markup=False, stderr=True)
|
||||
self.driver_class = driver_class or self.get_driver_class()
|
||||
@@ -558,6 +559,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
record=True,
|
||||
legacy_windows=False,
|
||||
)
|
||||
screen_render = self.screen._compositor.render(full=True)
|
||||
console.print(screen_render)
|
||||
|
||||
@@ -511,45 +511,6 @@ class SpacingProperty:
|
||||
obj.refresh(layout=True)
|
||||
|
||||
|
||||
class DocksProperty:
|
||||
"""Descriptor for getting and setting the docks property. This property
|
||||
is used to define docks and their location on screen.
|
||||
"""
|
||||
|
||||
def __get__(
|
||||
self, obj: StylesBase, objtype: type[StylesBase] | None = None
|
||||
) -> tuple[DockGroup, ...]:
|
||||
"""Get the Docks property
|
||||
|
||||
Args:
|
||||
obj (Styles): The ``Styles`` object.
|
||||
objtype (type[Styles]): The ``Styles`` class.
|
||||
|
||||
Returns:
|
||||
tuple[DockGroup, ...]: A ``tuple`` containing the defined docks.
|
||||
"""
|
||||
if obj.has_rule("docks"):
|
||||
return obj.get_rule("docks")
|
||||
from .styles import DockGroup
|
||||
|
||||
return (DockGroup("_default", "top", 1),)
|
||||
|
||||
def __set__(self, obj: StylesBase, docks: Iterable[DockGroup] | None):
|
||||
"""Set the Docks property
|
||||
|
||||
Args:
|
||||
obj (Styles): The ``Styles`` object.
|
||||
docks (Iterable[DockGroup]): Iterable of DockGroups
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
if docks is None:
|
||||
if obj.clear_rule("docks"):
|
||||
obj.refresh(layout=True)
|
||||
else:
|
||||
if obj.set_rule("docks", tuple(docks)):
|
||||
obj.refresh(layout=True)
|
||||
|
||||
|
||||
class DockProperty:
|
||||
"""Descriptor for getting and setting the dock property. The dock property
|
||||
allows you to specify which edge you want to fix a Widget to.
|
||||
|
||||
@@ -21,7 +21,6 @@ from ._style_properties import (
|
||||
BoxProperty,
|
||||
ColorProperty,
|
||||
DockProperty,
|
||||
DocksProperty,
|
||||
FractionalProperty,
|
||||
IntegerProperty,
|
||||
LayoutProperty,
|
||||
@@ -115,7 +114,6 @@ class RulesMap(TypedDict, total=False):
|
||||
max_height: Scalar
|
||||
|
||||
dock: str
|
||||
docks: tuple[DockGroup, ...]
|
||||
|
||||
overflow_x: Overflow
|
||||
overflow_y: Overflow
|
||||
@@ -252,7 +250,6 @@ class StylesBase(ABC):
|
||||
max_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False)
|
||||
|
||||
dock = DockProperty()
|
||||
docks = DocksProperty()
|
||||
|
||||
overflow_x = StringEnumProperty(VALID_OVERFLOW, "hidden")
|
||||
overflow_y = StringEnumProperty(VALID_OVERFLOW, "hidden")
|
||||
@@ -746,14 +743,6 @@ class Styles(StylesBase):
|
||||
append_declaration("offset", f"{x} {y}")
|
||||
if has_rule("dock"):
|
||||
append_declaration("dock", rules["dock"])
|
||||
if has_rule("docks"):
|
||||
append_declaration(
|
||||
"docks",
|
||||
" ".join(
|
||||
(f"{name}={edge}/{z}" if z else f"{name}={edge}")
|
||||
for name, edge, z in rules["docks"]
|
||||
),
|
||||
)
|
||||
if has_rule("layers"):
|
||||
append_declaration("layers", " ".join(self.layers))
|
||||
if has_rule("layer"):
|
||||
@@ -970,7 +959,6 @@ if __name__ == "__main__":
|
||||
styles.visibility = "hidden"
|
||||
styles.border = ("solid", "rgb(10,20,30)")
|
||||
styles.outline_right = ("solid", "red")
|
||||
styles.docks = "foo bar"
|
||||
styles.text_style = "italic"
|
||||
styles.dock = "bar"
|
||||
styles.layers = "foo bar"
|
||||
|
||||
@@ -504,99 +504,3 @@ class Stylesheet:
|
||||
apply(node.horizontal_scrollbar)
|
||||
if node.show_horizontal_scrollbar and node.show_vertical_scrollbar:
|
||||
apply(node.scrollbar_corner)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich.traceback import install
|
||||
|
||||
install(show_locals=True)
|
||||
|
||||
class Widget(DOMNode):
|
||||
pass
|
||||
|
||||
class View(DOMNode):
|
||||
pass
|
||||
|
||||
class App(DOMNode):
|
||||
pass
|
||||
|
||||
app = App()
|
||||
main_view = View(id="main")
|
||||
help_view = View(id="help")
|
||||
app._add_child(main_view)
|
||||
app._add_child(help_view)
|
||||
|
||||
widget1 = Widget(id="widget1")
|
||||
widget2 = Widget(id="widget2")
|
||||
sidebar = Widget(id="sidebar")
|
||||
sidebar.add_class("float")
|
||||
|
||||
helpbar = Widget(id="helpbar")
|
||||
helpbar.add_class("float")
|
||||
|
||||
main_view._add_child(widget1)
|
||||
main_view._add_child(widget2)
|
||||
main_view._add_child(sidebar)
|
||||
|
||||
sub_view = View(id="sub")
|
||||
sub_view.add_class("-subview")
|
||||
main_view._add_child(sub_view)
|
||||
|
||||
tooltip = Widget(id="tooltip")
|
||||
tooltip.add_class("float", "transient")
|
||||
sub_view._add_child(tooltip)
|
||||
|
||||
help = Widget(id="markdown")
|
||||
help_view._add_child(help)
|
||||
help_view._add_child(helpbar)
|
||||
|
||||
from rich import print
|
||||
|
||||
print(app.tree)
|
||||
print()
|
||||
|
||||
DEFAULT_CSS = """
|
||||
App > View {
|
||||
layout: dock;
|
||||
docks: sidebar=left | widgets=top;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
dock-group: sidebar;
|
||||
}
|
||||
|
||||
#widget1 {
|
||||
text: on blue;
|
||||
dock-group: widgets;
|
||||
}
|
||||
|
||||
#widget2 {
|
||||
text: on red;
|
||||
dock-group: widgets;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source(CSS)
|
||||
|
||||
print(stylesheet.css)
|
||||
|
||||
# print(stylesheet.error_renderable)
|
||||
|
||||
# print(widget1.styles)
|
||||
|
||||
# stylesheet.apply(widget1)
|
||||
|
||||
# print(widget1.styles)
|
||||
|
||||
# print(stylesheet.css)
|
||||
|
||||
# from .query import DOMQuery
|
||||
|
||||
# tests = ["View", "App > View", "Widget.float", ".float.transient", "*"]
|
||||
|
||||
# for test in tests:
|
||||
# print("")
|
||||
# print(f"[b]{test}")
|
||||
# print(app.query(test))
|
||||
|
||||
0
tests/snapshot_tests/__init__.py
Normal file
0
tests/snapshot_tests/__init__.py
Normal file
1248
tests/snapshot_tests/__snapshots__/test_snapshots.ambr
Normal file
1248
tests/snapshot_tests/__snapshots__/test_snapshots.ambr
Normal file
File diff suppressed because one or more lines are too long
181
tests/snapshot_tests/conftest.py
Normal file
181
tests/snapshot_tests/conftest.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from operator import attrgetter
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Union, List, Optional, Callable, Iterable
|
||||
|
||||
import pytest
|
||||
from _pytest.config import ExitCode
|
||||
from _pytest.fixtures import FixtureRequest
|
||||
from _pytest.main import Session
|
||||
from _pytest.terminal import TerminalReporter
|
||||
from jinja2 import Template
|
||||
from rich.console import Console
|
||||
from syrupy import SnapshotAssertion
|
||||
|
||||
from textual._doc import take_svg_screenshot
|
||||
from textual._import_app import import_app
|
||||
from textual.app import App
|
||||
|
||||
TEXTUAL_SNAPSHOT_SVG_KEY = pytest.StashKey[str]()
|
||||
TEXTUAL_ACTUAL_SVG_KEY = pytest.StashKey[str]()
|
||||
TEXTUAL_SNAPSHOT_PASS = pytest.StashKey[bool]()
|
||||
TEXTUAL_APP_KEY = pytest.StashKey[App]()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def snap_compare(
|
||||
snapshot: SnapshotAssertion, request: FixtureRequest
|
||||
) -> Callable[[str], bool]:
|
||||
"""
|
||||
This fixture returns a function which can be used to compare the output of a Textual
|
||||
app with the output of the same app in the past. This is snapshot testing, and it
|
||||
used to catch regressions in output.
|
||||
"""
|
||||
|
||||
def compare(
|
||||
app_path: str,
|
||||
press: Iterable[str] = ("_",),
|
||||
terminal_size: tuple[int, int] = (24, 80),
|
||||
) -> bool:
|
||||
"""
|
||||
Compare a current screenshot of the app running at app_path, with
|
||||
a previously accepted (validated by human) snapshot stored on disk.
|
||||
When the `--snapshot-update` flag is supplied (provided by syrupy),
|
||||
the snapshot on disk will be updated to match the current screenshot.
|
||||
|
||||
Args:
|
||||
app_path (str): The path of the app.
|
||||
press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause.
|
||||
terminal_size (tuple[int, int]): A pair of integers (rows, columns), representing terminal size.
|
||||
|
||||
Returns:
|
||||
bool: True if the screenshot matches the snapshot.
|
||||
"""
|
||||
node = request.node
|
||||
app = import_app(app_path)
|
||||
actual_screenshot = take_svg_screenshot(
|
||||
app=app,
|
||||
press=press,
|
||||
terminal_size=terminal_size,
|
||||
)
|
||||
result = snapshot == actual_screenshot
|
||||
|
||||
if result is False:
|
||||
# The split and join below is a mad hack, sorry...
|
||||
node.stash[TEXTUAL_SNAPSHOT_SVG_KEY] = "\n".join(str(snapshot).splitlines()[1:-1])
|
||||
node.stash[TEXTUAL_ACTUAL_SVG_KEY] = actual_screenshot
|
||||
node.stash[TEXTUAL_APP_KEY] = app
|
||||
else:
|
||||
node.stash[TEXTUAL_SNAPSHOT_PASS] = True
|
||||
|
||||
return result
|
||||
|
||||
return compare
|
||||
|
||||
|
||||
@dataclass
|
||||
class SvgSnapshotDiff:
|
||||
"""Model representing a diff between current screenshot of an app,
|
||||
and the snapshot on disk. This is ultimately intended to be used in
|
||||
a Jinja2 template."""
|
||||
snapshot: Optional[str]
|
||||
actual: Optional[str]
|
||||
test_name: str
|
||||
file_similarity: float
|
||||
path: PathLike
|
||||
line_number: int
|
||||
app: App
|
||||
environment: dict
|
||||
|
||||
|
||||
def pytest_sessionfinish(
|
||||
session: Session,
|
||||
exitstatus: Union[int, ExitCode],
|
||||
) -> None:
|
||||
"""Called after whole test run finished, right before returning the exit status to the system.
|
||||
Generates the snapshot report and writes it to disk.
|
||||
"""
|
||||
diffs: List[SvgSnapshotDiff] = []
|
||||
num_snapshots_passing = 0
|
||||
for item in session.items:
|
||||
|
||||
# Grab the data our fixture attached to the pytest node
|
||||
num_snapshots_passing += int(item.stash.get(TEXTUAL_SNAPSHOT_PASS, False))
|
||||
snapshot_svg = item.stash.get(TEXTUAL_SNAPSHOT_SVG_KEY, None)
|
||||
actual_svg = item.stash.get(TEXTUAL_ACTUAL_SVG_KEY, None)
|
||||
app = item.stash.get(TEXTUAL_APP_KEY, None)
|
||||
|
||||
if snapshot_svg and actual_svg and app:
|
||||
path, line_index, name = item.reportinfo()
|
||||
diffs.append(
|
||||
SvgSnapshotDiff(
|
||||
snapshot=str(snapshot_svg),
|
||||
actual=str(actual_svg),
|
||||
file_similarity=100
|
||||
* difflib.SequenceMatcher(
|
||||
a=str(snapshot_svg), b=str(actual_svg)
|
||||
).ratio(),
|
||||
test_name=name,
|
||||
path=path,
|
||||
line_number=line_index + 1,
|
||||
app=app,
|
||||
environment=dict(os.environ),
|
||||
)
|
||||
)
|
||||
|
||||
if diffs:
|
||||
diff_sort_key = attrgetter("file_similarity")
|
||||
diffs = sorted(diffs, key=diff_sort_key)
|
||||
|
||||
conftest_path = Path(__file__)
|
||||
snapshot_template_path = (
|
||||
conftest_path.parent / "snapshot_report_template.jinja2"
|
||||
)
|
||||
snapshot_report_path_dir = conftest_path.parent / "output"
|
||||
snapshot_report_path_dir.mkdir(parents=True, exist_ok=True)
|
||||
snapshot_report_path = snapshot_report_path_dir / "snapshot_report.html"
|
||||
|
||||
template = Template(snapshot_template_path.read_text())
|
||||
|
||||
num_fails = len(diffs)
|
||||
num_snapshot_tests = len(diffs) + num_snapshots_passing
|
||||
|
||||
rendered_report = template.render(
|
||||
diffs=diffs,
|
||||
passes=num_snapshots_passing,
|
||||
fails=num_fails,
|
||||
pass_percentage=100 * (num_snapshots_passing / max(num_snapshot_tests, 1)),
|
||||
fail_percentage=100 * (num_fails / max(num_snapshot_tests, 1)),
|
||||
num_snapshot_tests=num_snapshot_tests,
|
||||
now=datetime.utcnow(),
|
||||
)
|
||||
with open(snapshot_report_path, "w+", encoding="utf-8") as snapshot_file:
|
||||
snapshot_file.write(rendered_report)
|
||||
|
||||
session.config._textual_snapshots = diffs
|
||||
session.config._textual_snapshot_html_report = snapshot_report_path
|
||||
|
||||
|
||||
def pytest_terminal_summary(
|
||||
terminalreporter: TerminalReporter,
|
||||
exitstatus: ExitCode,
|
||||
config: pytest.Config,
|
||||
) -> None:
|
||||
"""Add a section to terminal summary reporting.
|
||||
Displays the link to the snapshot report that was generated in a prior hook.
|
||||
"""
|
||||
diffs = getattr(config, "_textual_snapshots", None)
|
||||
console = Console()
|
||||
if diffs:
|
||||
snapshot_report_location = config._textual_snapshot_html_report
|
||||
console.rule("[b red]Textual Snapshot Report", style="red")
|
||||
console.print(f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n"
|
||||
f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n")
|
||||
console.print(f"[dim]{snapshot_report_location}\n")
|
||||
console.rule(style="red")
|
||||
197
tests/snapshot_tests/snapshot_report_template.jinja2
Normal file
197
tests/snapshot_tests/snapshot_report_template.jinja2
Normal file
@@ -0,0 +1,197 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Textual Snapshot Test Report</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.1/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-iYQeCzEYFbKjA/T2uDLTpkwGzCiq6soy8tYaI1GyVh/UjpbCx/TYkiZhlZB6+fzT" crossorigin="anonymous">
|
||||
<style>
|
||||
.overlay-container {
|
||||
position: relative;
|
||||
}
|
||||
.diff-wrapper-actual {
|
||||
mix-blend-mode: difference;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4" style="background-color:#F4F8F7;">
|
||||
<div class="col-8 p-4">
|
||||
<h4>
|
||||
<strong>Textual</strong> Snapshot Tests
|
||||
</h4>
|
||||
<span class="text-muted">Showing diffs for {{ fails }} mismatched snapshot(s)</span>
|
||||
</div>
|
||||
<div class="col p-4">
|
||||
<div class="w-100 d-flex justify-content-end mb-1 mt-2">
|
||||
<span class="text-danger">
|
||||
<strong>{{ diffs | length }}</strong> snapshots changed
|
||||
</span>
|
||||
<span class="text-muted mx-2">·</span>
|
||||
<span class="text-success">
|
||||
<strong>{{ passes }}</strong> snapshots matched
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar bg-danger" role="progressbar" aria-label="Segment one"
|
||||
style="width: {{ fail_percentage }}%"
|
||||
aria-valuenow="{{ fails }}" aria-valuemin="0" aria-valuemax="{{ num_snapshot_tests }}"></div>
|
||||
<div class="progress-bar bg-success" role="progressbar" aria-label="Segment two"
|
||||
style="width: {{ pass_percentage }}%"
|
||||
aria-valuenow="{{ num_snapshot_tests }}" aria-valuemin="0"
|
||||
aria-valuemax="{{ num_snapshot_tests }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for diff in diffs %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span class="font-monospace mt-1">
|
||||
<span class="fw-bold">{{ diff.test_name }}</span>
|
||||
<span class="text-muted px-2">
|
||||
{{ diff.path }}:{{ diff.line_number }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="form-check form-switch mt-1">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="flexSwitchCheckDefault" onchange="toggleOverlayCheckbox(this, {{ loop.index0 }})">
|
||||
<label class="form-check-label text-muted" for="flexSwitchCheckDefault">
|
||||
Show difference
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{{ diff.actual }}
|
||||
<div class="w-100 d-flex justify-content-center mt-1">
|
||||
<span class="small">Output from test (<a href="#" class="link-primary mb-0"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#environmentModal">More info</a>)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="overlay-container">
|
||||
<div class="diff-wrapper-actual" id="diff-overlay-{{ loop.index0 }}" hidden>
|
||||
{{ diff.actual }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="diff-wrapper-snapshot">
|
||||
{{ diff.snapshot }}
|
||||
</div>
|
||||
<div class="w-100 d-flex justify-content-center mt-1">
|
||||
<span class="small">Historical snapshot</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{# Modal with debug info: #}
|
||||
<div class="modal modal-lg fade" id="environmentModal" tabindex="-1"
|
||||
aria-labelledby="environmentModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="environmentModalLabel">More info for <span
|
||||
class="font-monospace">{{ diff.test_name }}</span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body overflow-auto">
|
||||
<h5>Textual App State</h5>
|
||||
<table class="table mb-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Variable</th>
|
||||
<th scope="col">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="font-monospace">app.console.legacy_windows</td>
|
||||
<td class="font-monospace">{{ diff.app.console.legacy_windows }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace">app.console.size</td>
|
||||
<td class="font-monospace">{{ diff.app.console.size }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h5>Environment (<span class="font-monospace">os.environ</span>)</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Variable</th>
|
||||
<th scope="col">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, value in diff.environment.items() %}
|
||||
<tr>
|
||||
<td class="font-monospace">{{ key }}</td>
|
||||
<td class="font-monospace">{{ value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="row" style="background-color:#F4F8F7;">
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<p class="card-text">If you're happy with the test output, run <span class="font-monospace text-primary">pytest</span> with the <span
|
||||
class="font-monospace text-primary">--snapshot-update</span> flag to update the snapshot.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="w-100 d-flex p-4 justify-content-center">
|
||||
<p class="text-muted">Report generated at UTC {{ now }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<script type="application/javascript">
|
||||
function toggleOverlayCheckbox(element, index) {
|
||||
const overlay = document.getElementById(`diff-overlay-${index}`)
|
||||
overlay.hidden = !overlay.hidden
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
30
tests/snapshot_tests/test_snapshots.py
Normal file
30
tests/snapshot_tests/test_snapshots.py
Normal file
@@ -0,0 +1,30 @@
|
||||
def test_grid_layout_basic(snap_compare):
|
||||
assert snap_compare("docs/examples/guide/layout/grid_layout1.py")
|
||||
|
||||
|
||||
def test_grid_layout_basic_overflow(snap_compare):
|
||||
assert snap_compare("docs/examples/guide/layout/grid_layout2.py")
|
||||
|
||||
|
||||
def test_grid_layout_gutter(snap_compare):
|
||||
assert snap_compare("docs/examples/guide/layout/grid_layout7_gutter.py")
|
||||
|
||||
|
||||
def test_layers(snap_compare):
|
||||
assert snap_compare("docs/examples/guide/layout/layers.py")
|
||||
|
||||
|
||||
def test_center_layout(snap_compare):
|
||||
assert snap_compare("docs/examples/guide/layout/center_layout.py")
|
||||
|
||||
|
||||
def test_horizontal_layout(snap_compare):
|
||||
assert snap_compare("docs/examples/guide/layout/horizontal_layout.py")
|
||||
|
||||
|
||||
def test_vertical_layout(snap_compare):
|
||||
assert snap_compare("docs/examples/guide/layout/vertical_layout.py")
|
||||
|
||||
|
||||
def test_dock_layout_sidebar(snap_compare):
|
||||
assert snap_compare("docs/examples/guide/layout/dock_layout2_sidebar.py")
|
||||
Reference in New Issue
Block a user