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

This commit is contained in:
Darren Burns
2022-11-18 16:35:50 +00:00
45 changed files with 685 additions and 283 deletions

View File

@@ -13,6 +13,6 @@ jobs:
with:
issue-number: ${{ github.event.issue.number }}
body: |
Did we solve your problem?
Glad we could help!
Don't forget to [star](https://github.com/Textualize/textual) the repository!
Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.

View File

@@ -1,5 +1,6 @@
# Change Log
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
@@ -21,21 +22,30 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- It is now possible to `await` a `Widget.remove`.
https://github.com/Textualize/textual/issues/1094
- It is now possible to `await` a `DOMQuery.remove`. Note that this changes
the return value of `DOMQuery.remove`, which uses to return `self`.
the return value of `DOMQuery.remove`, which used to return `self`.
https://github.com/Textualize/textual/issues/1094
- Added Pilot.wait_for_animation
- Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121
- Added a `Label` widget https://github.com/Textualize/textual/issues/1190
- Support lazy-instantiated Screens (callables in App.SCREENS) https://github.com/Textualize/textual/pull/1185
- Display of keys in footer has more sensible defaults https://github.com/Textualize/textual/pull/1213
- Add App.get_key_display, allowing custom key_display App-wide https://github.com/Textualize/textual/pull/1213
### Changed
- Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145
- Widget.call_later has been renamed to Widget.call_after_refresh.
- Button variant values are now checked at runtime. https://github.com/Textualize/textual/issues/1189
- Added caching of some properties in Styles object
### Fixed
- Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026
- Fixed issues with animation. Now objects of different types may be animated.
- Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175
- Fixed auto-width in horizontal containers https://github.com/Textualize/textual/pull/1155
- Fixed Input cursor invisible when placeholder empty https://github.com/Textualize/textual/pull/1202
- Fixed deadlock when removing widgets from the App https://github.com/Textualize/textual/pull/1219
## [0.4.0] - 2022-11-08

10
docs.md
View File

@@ -1,14 +1,14 @@
# Documentation Workflow
* [Install Hatch](https://hatch.pypa.io/latest/install/)
* Run the live-reload server using `hatch run docs:serve` from the project root
* Ensure you're inside a *Python 3.10+* virtual environment
* Run the live-reload server using `mkdocs serve` from the project root
* Create new pages by adding new directories and Markdown files inside `docs/*`
## Commands
- `hatch run docs:serve` - Start the live-reloading docs server.
- `hatch run docs:build` - Build the documentation site.
- `hatch run docs:help` - Print help message and exit.
- `mkdocs serve` - Start the live-reloading docs server.
- `mkdocs build` - Build the documentation site.
- `mkdocs -h` - Print help message and exit.
## Project layout

1
docs/api/label.md Normal file
View File

@@ -0,0 +1 @@
::: textual.widgets.Label

View File

@@ -2,3 +2,15 @@ willmcgugan:
name: Will McGugan
description: CEO / code-monkey
avatar: https://github.com/willmcgugan.png
darrenburns:
name: Darren Burns
description: Code-monkey
avatar: https://github.com/darrenburns.png
davep:
name: Dave Pearson
description: Code-monkey
avatar: https://github.com/davep.png
rodrigo:
name: Rodrigo Girão Serrão
description: Code-monkey
avatar: https://github.com/rodrigogiraoserrao.png

View File

@@ -0,0 +1,12 @@
from textual.app import App, ComposeResult
from textual.widgets import Label
class LabelApp(App):
def compose(self) -> ComposeResult:
yield Label("Hello, world!")
if __name__ == "__main__":
app = LabelApp()
app.run()

View File

@@ -25,7 +25,7 @@ textual run my_app.py
The `run` sub-command will first look for a `App` instance called `app` in the global scope of your Python file. If there is no `app`, it will create an instance of the first `App` class it finds and run that.
Alternatively, you can add the name of an `App` instance or class after a colon to run a specific app in the Python file. Here's an example:
Alternatively, you can add the name of an `App` instance or class after a colon to run a specific app in the Python file. Here's an example:
```bash
textual run my_app.py:alternative_app
@@ -119,6 +119,6 @@ class LogApp(App):
self.log(self.tree)
if __name__ == "__main__":
LogApp.run()
LogApp().run()
```

33
docs/widgets/label.md Normal file
View File

@@ -0,0 +1,33 @@
# Label
A widget which displays static text, but which can also contain more complex Rich renderables.
- [ ] Focusable
- [ ] Container
## Example
The example below shows how you can use a `Label` widget to display some text.
=== "Output"
```{.textual path="docs/examples/widgets/label.py"}
```
=== "label.py"
```python
--8<-- "docs/examples/widgets/label.py"
```
## Reactive Attributes
This widget has no reactive attributes.
## Messages
This widget sends no messages.
## See Also
* [Label](../api/label.md) code reference

View File

@@ -1,14 +1,14 @@
# Static
A widget which displays static content.
Can be used for simple text labels, but can also contain more complex Rich renderables.
Can be used for Rich renderables and can also for the base for other types of widgets.
- [ ] Focusable
- [x] Container
- [ ] Container
## Example
The example below shows how you can use a `Static` widget as a simple text label.
The example below shows how you can use a `Static` widget as a simple text label (but see [Label](./label.md) as a way of displaying text).
=== "Output"
@@ -32,3 +32,4 @@ This widget sends no messages.
## See Also
* [Static](../api/static.md) code reference
* [Label](./label.md)

View File

@@ -13,7 +13,7 @@ from textual.containers import Horizontal
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Footer, Button, Static
from textual.widgets import Footer, Button, Label
from textual.css.query import DOMQuery
from textual.reactive import reactive
from textual.binding import Binding
@@ -33,10 +33,10 @@ class Help(Screen):
Returns:
ComposeResult: The result of composing the help screen.
"""
yield Static(Markdown(Path(__file__).with_suffix(".md").read_text()))
yield Label(Markdown(Path(__file__).with_suffix(".md").read_text()))
class WinnerMessage(Static):
class WinnerMessage(Label):
"""Widget to tell the user they have won."""
MIN_MOVES: Final = 14
@@ -91,9 +91,9 @@ class GameHeader(Widget):
ComposeResult: The result of composing the game header.
"""
yield Horizontal(
Static(self.app.title, id="app-title"),
Static(id="moves"),
Static(id="progress"),
Label(self.app.title, id="app-title"),
Label(id="moves"),
Label(id="progress"),
)
def watch_moves(self, moves: int):
@@ -102,7 +102,7 @@ class GameHeader(Widget):
Args:
moves (int): The number of moves made.
"""
self.query_one("#moves", Static).update(f"Moves: {moves}")
self.query_one("#moves", Label).update(f"Moves: {moves}")
def watch_filled(self, filled: int):
"""Watch the on-count reactive and update when it changes.
@@ -110,7 +110,7 @@ class GameHeader(Widget):
Args:
filled (int): The number of cells that are currently on.
"""
self.query_one("#progress", Static).update(f"Filled: {filled}")
self.query_one("#progress", Label).update(f"Filled: {filled}")
class GameCell(Button):
@@ -311,7 +311,7 @@ class FiveByFive(App[None]):
CSS_PATH = "five_by_five.css"
"""The name of the stylesheet for the app."""
SCREENS = {"help": Help()}
SCREENS = {"help": Help}
"""The pre-loaded screens for the application."""
BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")]

View File

@@ -96,6 +96,7 @@ nav:
- "widgets/footer.md"
- "widgets/header.md"
- "widgets/input.md"
- "widgets/label.md"
- "widgets/static.md"
- "widgets/tree_control.md"
- API:
@@ -109,6 +110,7 @@ nav:
- "api/footer.md"
- "api/geometry.md"
- "api/header.md"
- "api/label.md"
- "api/list_view.md"
- "api/message_pump.md"
- "api/message.md"
@@ -216,10 +218,10 @@ extra_css:
extra:
social:
- icon: fontawesome/brands/twitter
- icon: fontawesome/brands/twitter
link: https://twitter.com/textualizeio
name: textualizeio on Twitter
- icon: fontawesome/brands/github
- icon: fontawesome/brands/github
link: https://github.com/textualize/textual/
name: Textual on Github
- icon: fontawesome/brands/discord

170
poetry.lock generated
View File

@@ -31,6 +31,24 @@ python-versions = ">=3.7"
[package.dependencies]
frozenlist = ">=1.1.0"
[[package]]
name = "anyio"
version = "3.6.2"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "dev"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[package.extras]
doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"]
trio = ["trio (>=0.16,<0.22)"]
[[package]]
name = "async-timeout"
version = "4.0.2"
@@ -144,7 +162,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7
[[package]]
name = "colored"
version = "1.4.3"
version = "1.4.4"
description = "Simple library for color and formatting to terminal"
category = "dev"
optional = false
@@ -182,7 +200,7 @@ python-versions = "*"
[[package]]
name = "exceptiongroup"
version = "1.0.1"
version = "1.0.4"
description = "Backport of PEP 654 (exception groups)"
category = "dev"
optional = false
@@ -250,7 +268,7 @@ typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""
[[package]]
name = "griffe"
version = "0.23.0"
version = "0.24.0"
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
category = "dev"
optional = false
@@ -258,10 +276,60 @@ python-versions = ">=3.7"
[package.dependencies]
cached-property = {version = "*", markers = "python_version < \"3.8\""}
colorama = ">=0.4"
[package.extras]
async = ["aiofiles (>=0.7,<1.0)"]
[[package]]
name = "h11"
version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[[package]]
name = "httpcore"
version = "0.16.1"
description = "A minimal low-level HTTP client."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
anyio = ">=3.0,<5.0"
certifi = "*"
h11 = ">=0.13,<0.15"
sniffio = ">=1.0.0,<2.0.0"
[package.extras]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]]
name = "httpx"
version = "0.23.1"
description = "The next generation HTTP client."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
certifi = "*"
httpcore = ">=0.15.0,<0.17.0"
rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]]
name = "identify"
version = "2.5.8"
@@ -390,7 +458,7 @@ mkdocs = ">=1.1"
[[package]]
name = "mkdocs-material"
version = "8.5.9"
version = "8.5.10"
description = "Documentation that simply works"
category = "dev"
optional = false
@@ -455,14 +523,14 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
[[package]]
name = "mkdocstrings-python"
version = "0.7.1"
version = "0.8.0"
description = "A Python handler for mkdocstrings."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
griffe = ">=0.11.1"
griffe = ">=0.24"
mkdocstrings = ">=0.19"
[[package]]
@@ -541,7 +609,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "pathspec"
version = "0.10.1"
version = "0.10.2"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
@@ -549,7 +617,7 @@ python-versions = ">=3.7"
[[package]]
name = "platformdirs"
version = "2.5.3"
version = "2.5.4"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
@@ -663,7 +731,7 @@ testing = ["coverage (==6.2)", "mypy (==0.931)"]
[[package]]
name = "pytest-asyncio"
version = "0.20.1"
version = "0.20.2"
description = "Pytest support for asyncio"
category = "dev"
optional = false
@@ -748,6 +816,20 @@ urllib3 = ">=1.21.1,<1.27"
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rfc3986"
version = "1.5.0"
description = "Validating URI References per RFC 3986"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
[package.extras]
idna2008 = ["idna"]
[[package]]
name = "rich"
version = "12.6.0"
@@ -793,6 +875,14 @@ category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "sniffio"
version = "1.3.0"
description = "Sniff out which async library your code is running under"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "syrupy"
version = "3.0.5"
@@ -871,7 +961,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "virtualenv"
version = "20.16.6"
version = "20.16.7"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
@@ -929,7 +1019,7 @@ dev = ["aiohttp", "click", "msgpack"]
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "0c33f462f79d31068c0d74743f47c1d713ecc8ba009eaef1bef832f1272e4b1a"
content-hash = "578d7f611a797d406b8db7c61f28796e81af2e637d9671caab9b4ea2b1cf93c6"
[metadata.files]
aiohttp = [
@@ -1025,6 +1115,10 @@ aiosignal = [
{file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
{file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
]
anyio = [
{file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
{file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
]
async-timeout = [
{file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
{file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"},
@@ -1085,7 +1179,7 @@ colorama = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
colored = [
{file = "colored-1.4.3.tar.gz", hash = "sha256:b7b48b9f40e8a65bbb54813d5d79dd008dc8b8c5638d5bbfd30fc5a82e6def7a"},
{file = "colored-1.4.4.tar.gz", hash = "sha256:04ff4d4dd514274fe3b99a21bb52fb96f2688c01e93fba7bef37221e7cb56ce0"},
]
commonmark = [
{file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
@@ -1148,8 +1242,8 @@ distlib = [
{file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"},
]
exceptiongroup = [
{file = "exceptiongroup-1.0.1-py3-none-any.whl", hash = "sha256:4d6c0aa6dd825810941c792f53d7b8d71da26f5e5f84f20f9508e8f2d33b140a"},
{file = "exceptiongroup-1.0.1.tar.gz", hash = "sha256:73866f7f842ede6cb1daa42c4af078e2035e5f7607f0e2c762cc51bb31bbe7b2"},
{file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"},
{file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"},
]
filelock = [
{file = "filelock-3.8.0-py3-none-any.whl", hash = "sha256:617eb4e5eedc82fc5f47b6d61e4d11cb837c56cb4544e39081099fa17ad109d4"},
@@ -1244,8 +1338,20 @@ gitpython = [
{file = "GitPython-3.1.29.tar.gz", hash = "sha256:cc36bfc4a3f913e66805a28e84703e419d9c264c1077e537b54f0e1af85dbefd"},
]
griffe = [
{file = "griffe-0.23.0-py3-none-any.whl", hash = "sha256:cfca5f523808109da3f8cfaa46e325fa2e5bef51120d1146e908c121b56475f0"},
{file = "griffe-0.23.0.tar.gz", hash = "sha256:a639e2968c8e27f56ebcc57f869a03cea7ac7e7f5684bd2429c665f761c4e7bd"},
{file = "griffe-0.24.0-py3-none-any.whl", hash = "sha256:6c6b64716155f27ef63377e2b04749079c359f06d9a6e638bb2f885cbe463360"},
{file = "griffe-0.24.0.tar.gz", hash = "sha256:afa92aeb8c5a4f2501693ffd607f820d7ade3ac2a36e34c43d39ee3486cec392"},
]
h11 = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
httpcore = [
{file = "httpcore-0.16.1-py3-none-any.whl", hash = "sha256:8d393db683cc8e35cc6ecb02577c5e1abfedde52b38316d038932a84b4875ecb"},
{file = "httpcore-0.16.1.tar.gz", hash = "sha256:3d3143ff5e1656a5740ea2f0c167e8e9d48c5a9bbd7f00ad1f8cff5711b08543"},
]
httpx = [
{file = "httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"},
{file = "httpx-0.23.1.tar.gz", hash = "sha256:202ae15319be24efe9a8bd4ed4360e68fde7b38bcc2ce87088d416f026667d19"},
]
identify = [
{file = "identify-2.5.8-py2.py3-none-any.whl", hash = "sha256:48b7925fe122720088aeb7a6c34f17b27e706b72c61070f27fe3789094233440"},
@@ -1326,8 +1432,8 @@ mkdocs-autorefs = [
{file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"},
]
mkdocs-material = [
{file = "mkdocs_material-8.5.9-py3-none-any.whl", hash = "sha256:143ea55843b3747b640e1110824d91e8a4c670352380e166e64959f9abe98862"},
{file = "mkdocs_material-8.5.9.tar.gz", hash = "sha256:45eeabb23d2caba8fa3b85c91d9ec8e8b22add716e9bba8faf16d56af8aa5622"},
{file = "mkdocs_material-8.5.10-py3-none-any.whl", hash = "sha256:51760fa4c9ee3ca0b3a661ec9f9817ec312961bb84ff19e5b523fdc5256e1d6c"},
{file = "mkdocs_material-8.5.10.tar.gz", hash = "sha256:7623608f746c6d9ff68a8ef01f13eddf32fa2cae5e15badb251f26d1196bc8f1"},
]
mkdocs-material-extensions = [
{file = "mkdocs_material_extensions-1.1-py3-none-any.whl", hash = "sha256:bcc2e5fc70c0ec50e59703ee6e639d87c7e664c0c441c014ea84461a90f1e902"},
@@ -1342,8 +1448,8 @@ mkdocstrings = [
{file = "mkdocstrings-0.19.0.tar.gz", hash = "sha256:efa34a67bad11229d532d89f6836a8a215937548623b64f3698a1df62e01cc3e"},
]
mkdocstrings-python = [
{file = "mkdocstrings-python-0.7.1.tar.gz", hash = "sha256:c334b382dca202dfa37071c182418a6df5818356a95d54362a2b24822ca3af71"},
{file = "mkdocstrings_python-0.7.1-py3-none-any.whl", hash = "sha256:a22060bfa374697678e9af4e62b020d990dad2711c98f7a9fac5c0345bef93c7"},
{file = "mkdocstrings-python-0.8.0.tar.gz", hash = "sha256:67f674a8b252fca0b9411c10fb923dd6aacc49ac55c59f738b78b06592ace43d"},
{file = "mkdocstrings_python-0.8.0-py3-none-any.whl", hash = "sha256:cbee42e53aeaae340d79d72e9bcf42f2b6abe4d11696597c76e3e86a4d9f05a0"},
]
msgpack = [
{file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"},
@@ -1509,12 +1615,12 @@ packaging = [
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
]
pathspec = [
{file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"},
{file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"},
{file = "pathspec-0.10.2-py3-none-any.whl", hash = "sha256:88c2606f2c1e818b978540f73ecc908e13999c6c3a383daf3705652ae79807a5"},
{file = "pathspec-0.10.2.tar.gz", hash = "sha256:8f6bf73e5758fd365ef5d58ce09ac7c27d2833a8d7da51712eac6e27e35141b0"},
]
platformdirs = [
{file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"},
{file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"},
{file = "platformdirs-2.5.4-py3-none-any.whl", hash = "sha256:af0276409f9a02373d540bf8480021a048711d572745aef4b7842dad245eba10"},
{file = "platformdirs-2.5.4.tar.gz", hash = "sha256:1006647646d80f16130f052404c6b901e80ee4ed6bef6792e1f238a8969106f7"},
]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
@@ -1545,8 +1651,8 @@ pytest-aiohttp = [
{file = "pytest_aiohttp-1.0.4-py3-none-any.whl", hash = "sha256:1d2dc3a304c2be1fd496c0c2fb6b31ab60cd9fc33984f761f951f8ea1eb4ca95"},
]
pytest-asyncio = [
{file = "pytest-asyncio-0.20.1.tar.gz", hash = "sha256:626699de2a747611f3eeb64168b3575f70439b06c3d0206e6ceaeeb956e65519"},
{file = "pytest_asyncio-0.20.1-py3-none-any.whl", hash = "sha256:2c85a835df33fda40fe3973b451e0c194ca11bc2c007eabff90bb3d156fc172b"},
{file = "pytest-asyncio-0.20.2.tar.gz", hash = "sha256:32a87a9836298a881c0ec637ebcc952cfe23a56436bdc0d09d1511941dd8a812"},
{file = "pytest_asyncio-0.20.2-py3-none-any.whl", hash = "sha256:07e0abf9e6e6b95894a39f688a4a875d63c2128f76c02d03d16ccbc35bcc0f8a"},
]
pytest-cov = [
{file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"},
@@ -1610,6 +1716,10 @@ requests = [
{file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"},
{file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"},
]
rfc3986 = [
{file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
{file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
]
rich = [
{file = "rich-12.6.0-py3-none-any.whl", hash = "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e"},
{file = "rich-12.6.0.tar.gz", hash = "sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"},
@@ -1626,6 +1736,10 @@ smmap = [
{file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"},
{file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"},
]
sniffio = [
{file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
{file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
]
syrupy = [
{file = "syrupy-3.0.5-py3-none-any.whl", hash = "sha256:6dc0472cc690782b517d509a2854b5ca552a9851acf43b8a847cfa1aed6f6b01"},
{file = "syrupy-3.0.5.tar.gz", hash = "sha256:928962a6c14abb2695be9a49b42a016a4e4abdb017a69104cde958f2faf01f98"},
@@ -1729,8 +1843,8 @@ urllib3 = [
{file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"},
]
virtualenv = [
{file = "virtualenv-20.16.6-py3-none-any.whl", hash = "sha256:186ca84254abcbde98180fd17092f9628c5fe742273c02724972a1d8a2035108"},
{file = "virtualenv-20.16.6.tar.gz", hash = "sha256:530b850b523c6449406dfba859d6345e48ef19b8439606c5d74d7d3c9e14d76e"},
{file = "virtualenv-20.16.7-py3-none-any.whl", hash = "sha256:efd66b00386fdb7dbe4822d172303f40cd05e50e01740b19ea42425cbe653e29"},
{file = "virtualenv-20.16.7.tar.gz", hash = "sha256:8691e3ff9387f743e00f6bb20f70121f5e4f596cae754531f2b3b3a1b1ac696e"},
]
watchdog = [
{file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"},

View File

@@ -44,7 +44,7 @@ nanoid = ">=2.0.0"
[tool.poetry.extras]
dev = ["aiohttp", "click", "msgpack"]
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
pytest = "^7.1.3"
black = "^22.3.0"
mypy = "^0.990"
@@ -57,9 +57,8 @@ pytest-aiohttp = "^1.0.4"
time-machine = "^2.6.0"
Jinja2 = "<3.1.0"
syrupy = "^3.0.0"
[tool.poetry.group.dev.dependencies]
mkdocs-rss-plugin = "^1.5.0"
httpx = "^0.23.1"
[tool.black]
includes = "src"

View File

@@ -18,6 +18,7 @@ from time import perf_counter
from typing import (
TYPE_CHECKING,
Any,
Callable,
Generic,
Iterable,
List,
@@ -44,7 +45,8 @@ from ._context import active_app
from ._event_broker import NoHandler, extract_handler_actions
from ._filter import LineFilter, Monochrome
from ._path import _make_path_object_relative
from ._typing import TypeAlias, Final
from ._typing import Final, TypeAlias
from .await_remove import AwaitRemove
from .binding import Binding, Bindings
from .css.query import NoMatches
from .css.stylesheet import Stylesheet
@@ -55,12 +57,13 @@ from .drivers.headless_driver import HeadlessDriver
from .features import FeatureFlag, parse_features
from .file_monitor import FileMonitor
from .geometry import Offset, Region, Size
from .keys import REPLACED_KEYS
from .keys import REPLACED_KEYS, _get_key_display
from .messages import CallbackType
from .reactive import Reactive
from .renderables.blank import Blank
from .screen import Screen
from .widget import AwaitMount, Widget, MountError
from .widget import AwaitMount, MountError, Widget
if TYPE_CHECKING:
from .devtools.client import DevtoolsClient
@@ -101,7 +104,6 @@ DEFAULT_COLORS = {
ComposeResult = Iterable[Widget]
RenderResult = RenderableType
AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]"
@@ -228,7 +230,7 @@ class App(Generic[ReturnType], DOMNode):
}
"""
SCREENS: dict[str, Screen] = {}
SCREENS: dict[str, Screen | Callable[[], Screen]] = {}
_BASE_PATH: str | None = None
CSS_PATH: CSSPathType = None
TITLE: str | None = None
@@ -332,7 +334,7 @@ class App(Generic[ReturnType], DOMNode):
self._registry: WeakSet[DOMNode] = WeakSet()
self._installed_screens: WeakValueDictionary[
str, Screen
str, Screen | Callable[[], Screen]
] = WeakValueDictionary()
self._installed_screens.update(**self.SCREENS)
@@ -354,6 +356,7 @@ class App(Generic[ReturnType], DOMNode):
else None
)
self._screenshot: str | None = None
self._dom_lock = asyncio.Lock()
@property
def return_value(self) -> ReturnType | None:
@@ -669,6 +672,22 @@ class App(Generic[ReturnType], DOMNode):
keys, action, description, show=show, key_display=key_display
)
def get_key_display(self, key: str) -> str:
"""For a given key, return how it should be displayed in an app
(e.g. in the Footer widget).
By key, we refer to the string used in the "key" argument for
a Binding instance. By overriding this method, you can ensure that
keys are displayed consistently throughout your app, without
needing to add a key_display to every binding.
Args:
key (str): The binding key string.
Returns:
str: The display string for the input key.
"""
return _get_key_display(key)
async def _press_keys(self, keys: Iterable[str]) -> None:
"""A task to send key events."""
app = self
@@ -706,7 +725,7 @@ class App(Generic[ReturnType], DOMNode):
# This conditional sleep can be removed after that issue is closed.
if key == "tab":
await asyncio.sleep(0.05)
await asyncio.sleep(0.02)
await asyncio.sleep(0.025)
await app._animator.wait_for_idle()
@asynccontextmanager
@@ -1000,12 +1019,15 @@ class App(Generic[ReturnType], DOMNode):
next_screen = self._installed_screens[screen]
except KeyError:
raise KeyError(f"No screen called {screen!r} installed") from None
if callable(next_screen):
next_screen = next_screen()
self._installed_screens[screen] = next_screen
else:
next_screen = screen
return next_screen
def _get_screen(self, screen: Screen | str) -> tuple[Screen, AwaitMount]:
"""Get an installed screen and a await mount object.
"""Get an installed screen and an AwaitMount object.
If the screen isn't running, it will be registered before it is run.
@@ -1560,7 +1582,7 @@ class App(Generic[ReturnType], DOMNode):
# Close pre-defined screens
for screen in self.SCREENS.values():
if screen._running:
if isinstance(screen, Screen) and screen._running:
await self._prune_node(screen)
# Close any remaining nodes
@@ -1938,6 +1960,48 @@ class App(Generic[ReturnType], DOMNode):
for child in widget.children:
push(child)
def _remove_nodes(self, widgets: list[Widget]) -> AwaitRemove:
"""Remove nodes from DOM, and return an awaitable that awaits cleanup.
Args:
widgets (list[Widget]): List of nodes to remvoe.
Returns:
AwaitRemove: Awaitable that returns when the nodes have been fully removed.
"""
async def prune_widgets_task(
widgets: list[Widget], finished_event: asyncio.Event
) -> None:
"""Prune widgets as a background task.
Args:
widgets (list[Widget]): Widgets to prune.
finished_event (asyncio.Event): Event to set when complete.
"""
try:
await self._prune_nodes(widgets)
finally:
finished_event.set()
removed_widgets = self._detach_from_dom(widgets)
self.refresh(layout=True)
finished_event = asyncio.Event()
asyncio.create_task(prune_widgets_task(removed_widgets, finished_event))
return AwaitRemove(finished_event)
async def _prune_nodes(self, widgets: list[Widget]) -> None:
"""Remove nodes and children.
Args:
widgets (Widget): _description_
"""
async with self._dom_lock:
for widget in widgets:
await self._prune_node(widget)
async def _prune_node(self, root: Widget) -> None:
"""Remove a node and its children. Children are removed before parents.

View File

@@ -22,7 +22,3 @@ def camel_to_snake(
return f"{lower}_{upper.lower()}"
return _re_snake.sub(repl, name).lower()
if __name__ == "__main__":
print(camel_to_snake("HelloWorldEvent"))

View File

@@ -1,6 +1,6 @@
from textual.app import App, ComposeResult
from textual.constants import BORDERS
from textual.widgets import Button, Static
from textual.widgets import Button, Label
from textual.containers import Vertical
@@ -48,7 +48,7 @@ class BorderApp(App):
def compose(self):
yield BorderButtons()
self.text = Static(TEXT, id="text")
self.text = Label(TEXT, id="text")
yield self.text
def on_button_pressed(self, event: Button.Pressed) -> None:

View File

@@ -68,7 +68,7 @@ ColorGroup.-active {
}
ColorLabel {
Label {
padding: 0 0 1 0;
content-align: center middle;
color: $text;

View File

@@ -2,7 +2,7 @@ from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.design import ColorSystem
from textual.widget import Widget
from textual.widgets import Button, Footer, Static
from textual.widgets import Button, Footer, Static, Label
class ColorButtons(Vertical):
@@ -28,10 +28,6 @@ class Content(Vertical):
pass
class ColorLabel(Static):
pass
class ColorsView(Vertical):
def compose(self) -> ComposeResult:
@@ -47,7 +43,7 @@ class ColorsView(Vertical):
for color_name in ColorSystem.COLOR_NAMES:
items: list[Widget] = [ColorLabel(f'"{color_name}"')]
items: list[Widget] = [Label(f'"{color_name}"')]
for level in LEVELS:
color = f"{color_name}-{level}" if level else color_name
item = ColorItem(

View File

@@ -8,7 +8,7 @@ from textual.containers import Container, Horizontal, Vertical
from textual.reactive import Reactive
from textual.scrollbar import ScrollBarRender
from textual.widget import Widget
from textual.widgets import Button, Footer, Static, Input
from textual.widgets import Button, Footer, Label, Input
VIRTUAL_SIZE = 100
WINDOW_SIZE = 10
@@ -27,7 +27,7 @@ class Bar(Widget):
animation_running = Reactive(False)
DEFAULT_CSS = """
Bar {
background: $surface;
color: $error;
@@ -37,7 +37,7 @@ class Bar(Widget):
background: $surface;
color: $success;
}
"""
def watch_animation_running(self, running: bool) -> None:
@@ -67,14 +67,14 @@ class EasingApp(App):
self.animated_bar.position = START_POSITION
duration_input = Input("1.0", placeholder="Duration", id="duration-input")
self.opacity_widget = Static(
self.opacity_widget = Label(
f"[b]Welcome to Textual![/]\n\n{TEXT}", id="opacity-widget"
)
yield EasingButtons()
yield Vertical(
Horizontal(
Static("Animation Duration:", id="label"), duration_input, id="inputs"
Label("Animation Duration:", id="label"), duration_input, id="inputs"
),
Horizontal(
self.animated_bar,

View File

@@ -366,26 +366,3 @@ def parse(
is_default_rules=is_default_rules,
tie_breaker=tie_breaker,
)
if __name__ == "__main__":
print(parse_selectors("Foo > Bar.baz { foo: bar"))
css = """#something {
text: on red;
transition: offset 5.51s in_out_cubic;
offset-x: 100%;
}
"""
from textual.css.stylesheet import Stylesheet, StylesheetParseError
from rich.console import Console
console = Console()
stylesheet = Stylesheet()
try:
stylesheet.add_source(css)
except StylesheetParseError as e:
console.print(e.errors)
print(stylesheet)
print(stylesheet.css)

View File

@@ -356,16 +356,9 @@ class DOMQuery(Generic[QueryType]):
Returns:
AwaitRemove: An awaitable object that waits for the widgets to be removed.
"""
prune_finished_event = asyncio.Event()
app = active_app.get()
app.post_message_no_wait(
events.Prune(
app,
widgets=app._detach_from_dom(list(self)),
finished_flag=prune_finished_event,
)
)
return AwaitRemove(prune_finished_event)
await_remove = app._remove_nodes(list(self))
return await_remove
def set_styles(
self, css: str | None = None, **update_styles

View File

@@ -383,10 +383,3 @@ def percentage_string_to_float(string: str) -> float:
else:
float_percentage = float(string)
return float_percentage
if __name__ == "__main__":
print(Scalar.parse("3.14fr"))
s = Scalar.parse("23")
print(repr(s))
print(repr(s.cells))

View File

@@ -557,6 +557,7 @@ class StylesBase(ABC):
class Styles(StylesBase):
node: DOMNode | None = None
_rules: RulesMap = field(default_factory=dict)
_updates: int = 0
important: set[str] = field(default_factory=set)
@@ -577,6 +578,7 @@ class Styles(StylesBase):
Returns:
bool: ``True`` if a rule was cleared, or ``False`` if it was already not set.
"""
self._updates += 1
return self._rules.pop(rule, None) is not None
def get_rules(self) -> RulesMap:
@@ -592,6 +594,7 @@ class Styles(StylesBase):
Returns:
bool: ``True`` if the rule changed, otherwise ``False``.
"""
self._updates += 1
if value is None:
return self._rules.pop(rule, None) is not None
current = self._rules.get(rule)
@@ -610,6 +613,7 @@ class Styles(StylesBase):
def reset(self) -> None:
"""Reset the rules to initial state."""
self._updates += 1
self._rules.clear()
def merge(self, other: Styles) -> None:
@@ -618,10 +622,11 @@ class Styles(StylesBase):
Args:
other (Styles): A Styles object.
"""
self._updates += 1
self._rules.update(other._rules)
def merge_rules(self, rules: RulesMap) -> None:
self._updates += 1
self._rules.update(rules)
def extract_rules(
@@ -929,6 +934,18 @@ class RenderStyles(StylesBase):
self._base_styles = base
self._inline_styles = inline_styles
self._animate: BoundAnimator | None = None
self._updates: int = 0
self._rich_style: tuple[int, Style] | None = None
self._gutter: tuple[int, Spacing] | None = None
@property
def _cache_key(self) -> int:
"""A key key, that changes when any style is changed.
Returns:
int: An opaque integer.
"""
return self._updates + self._base_styles._updates + self._inline_styles._updates
@property
def base(self) -> Styles:
@@ -946,6 +963,21 @@ class RenderStyles(StylesBase):
assert self.node is not None
return self.node.rich_style
@property
def gutter(self) -> Spacing:
"""Get space around widget.
Returns:
Spacing: Space around widget content.
"""
if self._gutter is not None:
cache_key, gutter = self._gutter
if cache_key == self._updates:
return gutter
gutter = self.padding + self.border.spacing
self._gutter = (self._cache_key, gutter)
return gutter
def animate(
self,
attribute: str,
@@ -972,6 +1004,7 @@ class RenderStyles(StylesBase):
"""
if self._animate is None:
assert self.node is not None
self._animate = self.node.app.animator.bind(self)
assert self._animate is not None
self._animate(
@@ -1003,16 +1036,19 @@ class RenderStyles(StylesBase):
def merge_rules(self, rules: RulesMap) -> None:
self._inline_styles.merge_rules(rules)
self._updates += 1
def reset(self) -> None:
"""Reset the rules to initial state."""
self._inline_styles.reset()
self._updates += 1
def has_rule(self, rule: str) -> bool:
"""Check if a rule has been set."""
return self._inline_styles.has_rule(rule) or self._base_styles.has_rule(rule)
def set_rule(self, rule: str, value: object | None) -> bool:
self._updates += 1
return self._inline_styles.set_rule(rule, value)
def get_rule(self, rule: str, default: object = None) -> object:
@@ -1022,6 +1058,7 @@ class RenderStyles(StylesBase):
def clear_rule(self, rule_name: str) -> bool:
"""Clear a rule (from inline)."""
self._updates += 1
return self._inline_styles.clear_rule(rule_name)
def get_rules(self) -> RulesMap:
@@ -1037,25 +1074,3 @@ class RenderStyles(StylesBase):
styles.merge(self._inline_styles)
combined_css = styles.css
return combined_css
if __name__ == "__main__":
styles = Styles()
styles.display = "none"
styles.visibility = "hidden"
styles.border = ("solid", "rgb(10,20,30)")
styles.outline_right = ("solid", "red")
styles.text_style = "italic"
styles.dock = "bar"
styles.layers = "foo bar"
from rich import print
print(styles.text_style)
print(styles.text)
print(styles)
print(styles.css)
print(styles.extract_rules((0, 1, 0)))

View File

@@ -197,18 +197,3 @@ def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]:
name: list(tokenize_value(value, "__name__")) for name, value in values.items()
}
return value_tokens
if __name__ == "__main__":
from rich import print
css = """#something {
color: rgb(10,12,23)
}
"""
# transition: offset 500 in_out_cubic;
tokens = tokenize(css, __name__)
print(list(tokens))
print(tokenize_values({"primary": "rgb(10,20,30)", "secondary": "#ff00ff"}))

View File

@@ -222,11 +222,3 @@ def show_design(light: ColorSystem, dark: ColorSystem) -> Table:
table.add_column("Dark", justify="center")
table.add_row(make_shades(light), make_shades(dark))
return table
if __name__ == "__main__":
from .app import DEFAULT_COLORS
from rich import print
print(show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"]))

View File

@@ -236,17 +236,3 @@ class LinuxDriver(Driver):
finally:
with timer("selector.close"):
selector.close()
if __name__ == "__main__":
from rich.console import Console
console = Console()
from ..app import App
class MyApp(App):
async def on_mount(self, event: events.Mount) -> None:
self.set_timer(5, callback=self._close_messages)
MyApp.run()

View File

@@ -127,28 +127,6 @@ class Unmount(Mount, bubble=False, verbose=False):
"""Sent when a widget is unmounted and may not longer receive messages."""
class Prune(Event, bubble=False):
"""Sent to the app to ask it to prune one or more widgets from the DOM.
Attributes:
widgets (list[Widgets]): The list of widgets to prune.
finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done.
"""
def __init__(
self, sender: MessageTarget, widgets: list[Widget], finished_flag: asyncio.Event
) -> None:
"""Initialise the event.
Args:
widgets (list[Widgets]): The list of widgets to prune.
finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done.
"""
super().__init__(sender)
self.finished_flag = finished_flag
self.widgets = widgets
class Show(Event, bubble=False):
"""Sent when a widget has become visible."""

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import unicodedata
from enum import Enum
@@ -219,7 +220,34 @@ KEY_ALIASES = {
"ctrl+j": ["newline"],
}
KEY_DISPLAY_ALIASES = {
"up": "",
"down": "",
"left": "",
"right": "",
"backspace": "",
"escape": "ESC",
"enter": "",
}
def _get_key_aliases(key: str) -> list[str]:
"""Return all aliases for the given key, including the key itself"""
return [key] + KEY_ALIASES.get(key, [])
def _get_key_display(key: str) -> str:
"""Given a key (i.e. the `key` string argument to Binding __init__),
return the value that should be displayed in the app when referring
to this key (e.g. in the Footer widget)."""
display_alias = KEY_DISPLAY_ALIASES.get(key)
if display_alias:
return display_alias
original_key = REPLACED_KEYS.get(key, key)
try:
unicode_character = unicodedata.lookup(original_key.upper().replace("_", " "))
except KeyError:
return original_key.upper()
return unicode_character

View File

@@ -43,7 +43,7 @@ class Reactive(Generic[ReactiveType]):
layout (bool, optional): Perform a layout on change. Defaults to False.
repaint (bool, optional): Perform a repaint on change. Defaults to True.
init (bool, optional): Call watchers on initialize (post mount). Defaults to False.
always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
"""
def __init__(
@@ -76,7 +76,7 @@ class Reactive(Generic[ReactiveType]):
default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default.
layout (bool, optional): Perform a layout on change. Defaults to False.
repaint (bool, optional): Perform a repaint on change. Defaults to True.
always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
Returns:
Reactive: A Reactive instance which calls watchers or initialize.
@@ -292,7 +292,7 @@ class reactive(Reactive[ReactiveType]):
layout (bool, optional): Perform a layout on change. Defaults to False.
repaint (bool, optional): Perform a repaint on change. Defaults to True.
init (bool, optional): Call watchers on initialize (post mount). Defaults to True.
always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
"""
def __init__(

View File

@@ -25,9 +25,3 @@ class Blank:
for _ in range(height):
yield segment
yield line
if __name__ == "__main__":
from rich import print
print(Blank("red"))

View File

@@ -36,9 +36,3 @@ class VerticalGradient:
),
)
yield Segment(f"{width * ' '}\n", line_color)
if __name__ == "__main__":
from rich import print
print(VerticalGradient("red", "blue"))

View File

@@ -310,18 +310,19 @@ class Screen(Widget):
# Check for any widgets marked as 'dirty' (needs a repaint)
event.prevent_default()
if self.is_current:
if self._layout_required:
self._refresh_layout()
self._layout_required = False
self._dirty_widgets.clear()
if self._repaint_required:
self._dirty_widgets.clear()
self._dirty_widgets.add(self)
self._repaint_required = False
async with self.app._dom_lock:
if self.is_current:
if self._layout_required:
self._refresh_layout()
self._layout_required = False
self._dirty_widgets.clear()
if self._repaint_required:
self._dirty_widgets.clear()
self._dirty_widgets.add(self)
self._repaint_required = False
if self._dirty_widgets:
self.update_timer.resume()
if self._dirty_widgets:
self.update_timer.resume()
# The Screen is idle - a good opportunity to invoke the scheduled callbacks
await self._invoke_and_clear_callbacks()

View File

@@ -320,18 +320,3 @@ class ScrollBarCorner(Widget):
styles = self.parent.styles
color = styles.scrollbar_corner_color
return Blank(color)
if __name__ == "__main__":
from rich.console import Console
console = Console()
thickness = 2
console.print(f"Bars thickness: {thickness}")
console.print("Vertical bar:")
console.print(ScrollBarRender.render_bar(thickness=thickness))
console.print("Horizontal bar:")
console.print(ScrollBarRender.render_bar(vertical=False, thickness=thickness))

View File

@@ -523,15 +523,19 @@ class Widget(DOMNode):
# Decide the final resting place depending on what we've been asked
# to do.
insert_before: int | None = None
insert_after: int | None = None
if before is not None:
parent, before = self._find_mount_point(before)
parent, insert_before = self._find_mount_point(before)
elif after is not None:
parent, after = self._find_mount_point(after)
parent, insert_after = self._find_mount_point(after)
else:
parent = self
return AwaitMount(
self.app._register(parent, *widgets, before=before, after=after)
self.app._register(
parent, *widgets, before=insert_before, after=insert_after
)
)
def move_child(
@@ -560,7 +564,7 @@ class Widget(DOMNode):
if before is None and after is None:
raise WidgetError("One of `before` or `after` is required.")
elif before is not None and after is not None:
raise WidgetError("Only one of `before`or `after` can be handled.")
raise WidgetError("Only one of `before` or `after` can be handled.")
def _to_widget(child: int | Widget, called: str) -> Widget:
"""Ensure a given child reference is a Widget."""
@@ -697,7 +701,6 @@ class Widget(DOMNode):
Returns:
int: The height of the content.
"""
if self.is_container:
assert self._layout is not None
height = (
@@ -2114,15 +2117,9 @@ class Widget(DOMNode):
Returns:
AwaitRemove: An awaitable object that waits for the widget to be removed.
"""
prune_finished_event = AsyncEvent()
self.app.post_message_no_wait(
events.Prune(
self,
widgets=self.app._detach_from_dom([self]),
finished_flag=prune_finished_event,
)
)
return AwaitRemove(prune_finished_event)
await_remove = self.app._remove_nodes([self])
return await_remove
def render(self) -> RenderableType:
"""Get renderable for widget.

View File

@@ -15,6 +15,7 @@ if typing.TYPE_CHECKING:
from ._directory_tree import DirectoryTree
from ._footer import Footer
from ._header import Header
from ._label import Label
from ._list_view import ListView
from ._list_item import ListItem
from ._pretty import Pretty
@@ -34,6 +35,7 @@ __all__ = [
"Header",
"ListItem",
"ListView",
"Label",
"Placeholder",
"Pretty",
"Static",

View File

@@ -5,6 +5,7 @@ from ._checkbox import Checkbox as Checkbox
from ._directory_tree import DirectoryTree as DirectoryTree
from ._footer import Footer as Footer
from ._header import Header as Header
from ._label import Label as Label
from ._list_view import ListView as ListView
from ._list_item import ListItem as ListItem
from ._placeholder import Placeholder as Placeholder

View File

@@ -168,9 +168,9 @@ class Button(Static, can_focus=True):
"""Create a Button widget.
Args:
label (str): The text that appears within the button.
disabled (bool): Whether the button is disabled or not.
variant (ButtonVariant): The variant of the button.
label (str, optional): The text that appears within the button.
disabled (bool, optional): Whether the button is disabled or not.
variant (ButtonVariant, optional): The variant of the button.
name (str | None, optional): The name of the button.
id (str | None, optional): The ID of the button in the DOM.
classes (str | None, optional): The CSS classes of the button.
@@ -186,7 +186,7 @@ class Button(Static, can_focus=True):
if disabled:
self.add_class("-disabled")
self.variant = variant
self.variant = self.validate_variant(variant)
label: Reactive[RenderableType] = Reactive("")
variant = Reactive.init("default")
@@ -267,8 +267,8 @@ class Button(Static, can_focus=True):
"""Utility constructor for creating a success Button variant.
Args:
label (str): The text that appears within the button.
disabled (bool): Whether the button is disabled or not.
label (str, optional): The text that appears within the button.
disabled (bool, optional): Whether the button is disabled or not.
name (str | None, optional): The name of the button.
id (str | None, optional): The ID of the button in the DOM.
classes(str | None, optional): The CSS classes of the button.
@@ -298,8 +298,8 @@ class Button(Static, can_focus=True):
"""Utility constructor for creating a warning Button variant.
Args:
label (str): The text that appears within the button.
disabled (bool): Whether the button is disabled or not.
label (str, optional): The text that appears within the button.
disabled (bool, optional): Whether the button is disabled or not.
name (str | None, optional): The name of the button.
id (str | None, optional): The ID of the button in the DOM.
classes (str | None, optional): The CSS classes of the button.
@@ -329,8 +329,8 @@ class Button(Static, can_focus=True):
"""Utility constructor for creating an error Button variant.
Args:
label (str): The text that appears within the button.
disabled (bool): Whether the button is disabled or not.
label (str, optional): The text that appears within the button.
disabled (bool, optional): Whether the button is disabled or not.
name (str | None, optional): The name of the button.
id (str | None, optional): The ID of the button in the DOM.
classes (str | None, optional): The CSS classes of the button.

View File

@@ -7,6 +7,7 @@ from rich.console import RenderableType
from rich.text import Text
from .. import events
from ..keys import _get_key_display
from ..reactive import Reactive, watch
from ..widget import Widget
@@ -99,11 +100,12 @@ class Footer(Widget):
for action, bindings in action_to_bindings.items():
binding = bindings[0]
key_display = (
binding.key.upper()
if binding.key_display is None
else binding.key_display
)
if binding.key_display is None:
key_display = self.app.get_key_display(binding.key)
if key_display is None:
key_display = binding.key.upper()
else:
key_display = binding.key_display
hovered = self.highlight_key == binding.key
key_text = Text.assemble(
(f" {key_display} ", highlight_key_style if hovered else key_style),

View File

@@ -176,6 +176,10 @@ class Input(Widget, can_focus=True):
if self.has_focus:
cursor_style = self.get_component_rich_style("input--cursor")
if self._cursor_visible:
# If the placeholder is empty, there's no characters to stylise
# to make the cursor flash, so use a single space character
if len(placeholder) == 0:
placeholder = Text(" ")
placeholder.stylize(cursor_style, 0, 1)
return placeholder
return _InputRenderable(self, self._cursor_visible)

View File

@@ -0,0 +1,7 @@
"""Provides a simple Label widget."""
from ._static import Static
class Label(Static):
"""A simple label widget for displaying text-oriented renderables."""

View File

@@ -177,16 +177,16 @@ class Tabs(Widget):
Args:
tabs (list[Tab]): A list of Tab objects defining the tabs which should be rendered.
active_tab (str, optional): The name of the tab that should be active on first render.
active_tab_style (StyleType): Style to apply to the label of the active tab.
active_bar_style (StyleType): Style to apply to the underline of the active tab.
inactive_tab_style (StyleType): Style to apply to the label of inactive tabs.
inactive_bar_style (StyleType): Style to apply to the underline of inactive tabs.
inactive_text_opacity (float): Opacity of the text labels of inactive tabs.
animation_duration (float): The duration of the tab change animation, in seconds.
animation_function (str): The easing function to use for the tab change animation.
active_tab_style (StyleType, optional): Style to apply to the label of the active tab.
active_bar_style (StyleType, optional): Style to apply to the underline of the active tab.
inactive_tab_style (StyleType, optional): Style to apply to the label of inactive tabs.
inactive_bar_style (StyleType, optional): Style to apply to the underline of inactive tabs.
inactive_text_opacity (float, optional): Opacity of the text labels of inactive tabs.
animation_duration (float, optional): The duration of the tab change animation, in seconds.
animation_function (str, optional): The easing function to use for the tab change animation.
tab_padding (int, optional): The padding at the side of each tab. If None, tabs will
automatically be padded such that they fit the available horizontal space.
search_by_first_character (bool): If True, entering a character on your keyboard
search_by_first_character (bool, optional): If True, entering a character on your keyboard
will activate the next tab (in left-to-right order) with a label starting with
that character.
"""

View File

@@ -6166,6 +6166,163 @@
'''
# ---
# name: test_key_display
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-1765381587-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-1765381587-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-1765381587-r1 { fill: #e1e1e1 }
.terminal-1765381587-r2 { fill: #c5c8c6 }
.terminal-1765381587-r3 { fill: #dde8f3;font-weight: bold }
.terminal-1765381587-r4 { fill: #ddedf9 }
</style>
<defs>
<clipPath id="terminal-1765381587-clip-terminal">
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
</clipPath>
<clipPath id="terminal-1765381587-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-19">
<rect x="0" y="465.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-20">
<rect x="0" y="489.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-21">
<rect x="0" y="513.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-22">
<rect x="0" y="538.3" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1765381587-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">KeyDisplayApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-1765381587-clip-terminal)">
<rect fill="#1e1e1e" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="0" y="562.7" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="36.6" y="562.7" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="158.6" y="562.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="207.4" y="562.7" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="329.4" y="562.7" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="439.2" y="562.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="536.8" y="562.7" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="573.4" y="562.7" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="695.4" y="562.7" width="280.6" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-1765381587-matrix">
<text class="terminal-1765381587-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1765381587-line-0)">
</text><text class="terminal-1765381587-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1765381587-line-1)">
</text><text class="terminal-1765381587-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1765381587-line-2)">
</text><text class="terminal-1765381587-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1765381587-line-3)">
</text><text class="terminal-1765381587-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1765381587-line-4)">
</text><text class="terminal-1765381587-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1765381587-line-5)">
</text><text class="terminal-1765381587-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1765381587-line-6)">
</text><text class="terminal-1765381587-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1765381587-line-7)">
</text><text class="terminal-1765381587-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1765381587-line-8)">
</text><text class="terminal-1765381587-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1765381587-line-9)">
</text><text class="terminal-1765381587-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1765381587-line-10)">
</text><text class="terminal-1765381587-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1765381587-line-11)">
</text><text class="terminal-1765381587-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1765381587-line-12)">
</text><text class="terminal-1765381587-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1765381587-line-13)">
</text><text class="terminal-1765381587-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1765381587-line-14)">
</text><text class="terminal-1765381587-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1765381587-line-15)">
</text><text class="terminal-1765381587-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1765381587-line-16)">
</text><text class="terminal-1765381587-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1765381587-line-17)">
</text><text class="terminal-1765381587-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1765381587-line-18)">
</text><text class="terminal-1765381587-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1765381587-line-19)">
</text><text class="terminal-1765381587-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1765381587-line-20)">
</text><text class="terminal-1765381587-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1765381587-line-21)">
</text><text class="terminal-1765381587-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1765381587-line-22)">
</text><text class="terminal-1765381587-r3" x="0" y="581.2" textLength="36.6" clip-path="url(#terminal-1765381587-line-23)">&#160;?&#160;</text><text class="terminal-1765381587-r4" x="36.6" y="581.2" textLength="122" clip-path="url(#terminal-1765381587-line-23)">&#160;Question&#160;</text><text class="terminal-1765381587-r3" x="158.6" y="581.2" textLength="48.8" clip-path="url(#terminal-1765381587-line-23)">&#160;^q&#160;</text><text class="terminal-1765381587-r4" x="207.4" y="581.2" textLength="122" clip-path="url(#terminal-1765381587-line-23)">&#160;Quit&#160;app&#160;</text><text class="terminal-1765381587-r3" x="329.4" y="581.2" textLength="109.8" clip-path="url(#terminal-1765381587-line-23)">&#160;Escape!&#160;</text><text class="terminal-1765381587-r4" x="439.2" y="581.2" textLength="97.6" clip-path="url(#terminal-1765381587-line-23)">&#160;Escape&#160;</text><text class="terminal-1765381587-r3" x="536.8" y="581.2" textLength="36.6" clip-path="url(#terminal-1765381587-line-23)">&#160;A&#160;</text><text class="terminal-1765381587-r4" x="573.4" y="581.2" textLength="122" clip-path="url(#terminal-1765381587-line-23)">&#160;Letter&#160;A&#160;</text>
</g>
</g>
</svg>
'''
# ---
# name: test_layers
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">

View File

@@ -0,0 +1,36 @@
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Footer
class KeyDisplayApp(App):
"""Tests how keys are displayed in the Footer, and ensures
that overriding the key_displays works as expected.
Exercises both the built-in Textual key display replacements,
and user supplied replacements.
Will break when we update the Footer - but we should add a similar
test (or updated snapshot) for the updated Footer."""
BINDINGS = [
Binding("question_mark", "question", "Question"),
Binding("ctrl+q", "quit", "Quit app"),
Binding("escape", "escape", "Escape"),
Binding("a", "a", "Letter A"),
]
def compose(self) -> ComposeResult:
yield Footer()
def get_key_display(self, key: str) -> str:
key_display_replacements = {
"escape": "Escape!",
"ctrl+q": "^q",
}
display = key_display_replacements.get(key)
if display:
return display
return super().get_key_display(key)
app = KeyDisplayApp()
if __name__ == '__main__':
app.run()

View File

@@ -118,3 +118,9 @@ def test_css_property(file_name, snap_compare):
def test_multiple_css(snap_compare):
# Interaction between multiple CSS files and app-level/classvar CSS
assert snap_compare("snapshot_apps/multiple_css/multiple_css.py")
# --- Other ---
def test_key_display(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py")

View File

@@ -11,8 +11,37 @@ skip_py310 = pytest.mark.skipif(
)
async def test_installed_screens():
class ScreensApp(App):
SCREENS = {
"home": Screen, # Screen type
"one": Screen(), # Screen instance
"two": lambda: Screen() # Callable[[], Screen]
}
app = ScreensApp()
async with app.run_test() as pilot:
pilot.app.push_screen("home") # Instantiates and pushes the "home" screen
pilot.app.push_screen("one") # Pushes the pre-instantiated "one" screen
pilot.app.push_screen("home") # Pushes the single instance of "home" screen
pilot.app.push_screen("two") # Calls the callable, pushes returned Screen instance
assert len(app.screen_stack) == 5
assert app.screen_stack[1] is app.screen_stack[3]
assert app.screen is app.screen_stack[4]
assert isinstance(app.screen, Screen)
assert app.is_screen_installed(app.screen)
assert pilot.app.pop_screen()
assert pilot.app.pop_screen()
assert pilot.app.pop_screen()
assert pilot.app.pop_screen()
with pytest.raises(ScreenStackError):
pilot.app.pop_screen()
@skip_py310
@pytest.mark.asyncio
async def test_screens():
app = App()