diff --git a/.github/workflows/comment.yml b/.github/workflows/comment.yml
index 7191f1836..46cf1677f 100644
--- a/.github/workflows/comment.yml
+++ b/.github/workflows/comment.yml
@@ -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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c300711d7..185cbb530 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs.md b/docs.md
index ddc2bd439..63c47ed6a 100644
--- a/docs.md
+++ b/docs.md
@@ -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
diff --git a/docs/api/label.md b/docs/api/label.md
new file mode 100644
index 000000000..eee506a2c
--- /dev/null
+++ b/docs/api/label.md
@@ -0,0 +1 @@
+::: textual.widgets.Label
diff --git a/docs/blog/.authors.yml b/docs/blog/.authors.yml
index e233de92d..5ed343e2f 100644
--- a/docs/blog/.authors.yml
+++ b/docs/blog/.authors.yml
@@ -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
diff --git a/docs/examples/widgets/label.py b/docs/examples/widgets/label.py
new file mode 100644
index 000000000..43a50bb10
--- /dev/null
+++ b/docs/examples/widgets/label.py
@@ -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()
diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md
index fa0d1e7e9..8643073ff 100644
--- a/docs/guide/devtools.md
+++ b/docs/guide/devtools.md
@@ -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()
```
diff --git a/docs/widgets/label.md b/docs/widgets/label.md
new file mode 100644
index 000000000..96a31bcc6
--- /dev/null
+++ b/docs/widgets/label.md
@@ -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
diff --git a/docs/widgets/static.md b/docs/widgets/static.md
index 342e2daf7..c8e41606f 100644
--- a/docs/widgets/static.md
+++ b/docs/widgets/static.md
@@ -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)
diff --git a/examples/five_by_five.py b/examples/five_by_five.py
index 20bbea80f..3e668f04d 100644
--- a/examples/five_by_five.py
+++ b/examples/five_by_five.py
@@ -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")]
diff --git a/mkdocs.yml b/mkdocs.yml
index a04e15286..92cc4714c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -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
diff --git a/poetry.lock b/poetry.lock
index b98b0925f..59f0e2065 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -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"},
diff --git a/pyproject.toml b/pyproject.toml
index 937fd3590..bf55b935b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"
diff --git a/src/textual/app.py b/src/textual/app.py
index c3c1045f5..69cad52b1 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -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.
diff --git a/src/textual/case.py b/src/textual/case.py
index ba34e58a8..4ae4883f3 100644
--- a/src/textual/case.py
+++ b/src/textual/case.py
@@ -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"))
diff --git a/src/textual/cli/previews/borders.py b/src/textual/cli/previews/borders.py
index 613343443..e5d453d86 100644
--- a/src/textual/cli/previews/borders.py
+++ b/src/textual/cli/previews/borders.py
@@ -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:
diff --git a/src/textual/cli/previews/colors.css b/src/textual/cli/previews/colors.css
index 3af8eabd7..657a8aabb 100644
--- a/src/textual/cli/previews/colors.css
+++ b/src/textual/cli/previews/colors.css
@@ -68,7 +68,7 @@ ColorGroup.-active {
}
-ColorLabel {
+Label {
padding: 0 0 1 0;
content-align: center middle;
color: $text;
diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py
index 56ac645c6..e25c70d7f 100644
--- a/src/textual/cli/previews/colors.py
+++ b/src/textual/cli/previews/colors.py
@@ -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(
diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py
index 6edcdb82a..b81290302 100644
--- a/src/textual/cli/previews/easing.py
+++ b/src/textual/cli/previews/easing.py
@@ -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,
diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py
index fdfca677a..1560a009f 100644
--- a/src/textual/css/parse.py
+++ b/src/textual/css/parse.py
@@ -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)
diff --git a/src/textual/css/query.py b/src/textual/css/query.py
index ff1657a99..19f999e48 100644
--- a/src/textual/css/query.py
+++ b/src/textual/css/query.py
@@ -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
diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py
index 8c5fa39b9..0fb4f874b 100644
--- a/src/textual/css/scalar.py
+++ b/src/textual/css/scalar.py
@@ -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))
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index 17e5963a8..006f12910 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -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)))
diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py
index dbec369df..e13820fd6 100644
--- a/src/textual/css/tokenize.py
+++ b/src/textual/css/tokenize.py
@@ -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"}))
diff --git a/src/textual/design.py b/src/textual/design.py
index 2294de748..490aa9da2 100644
--- a/src/textual/design.py
+++ b/src/textual/design.py
@@ -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"]))
diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py
index f0e75e71e..8f61c4fb2 100644
--- a/src/textual/drivers/linux_driver.py
+++ b/src/textual/drivers/linux_driver.py
@@ -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()
diff --git a/src/textual/events.py b/src/textual/events.py
index 0eb5abac3..815f75f02 100644
--- a/src/textual/events.py
+++ b/src/textual/events.py
@@ -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."""
diff --git a/src/textual/keys.py b/src/textual/keys.py
index e6d386c68..aac14c138 100644
--- a/src/textual/keys.py
+++ b/src/textual/keys.py
@@ -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
diff --git a/src/textual/reactive.py b/src/textual/reactive.py
index 7cd88d630..c71070ba3 100644
--- a/src/textual/reactive.py
+++ b/src/textual/reactive.py
@@ -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__(
diff --git a/src/textual/renderables/blank.py b/src/textual/renderables/blank.py
index b83dbbc34..05552ac2f 100644
--- a/src/textual/renderables/blank.py
+++ b/src/textual/renderables/blank.py
@@ -25,9 +25,3 @@ class Blank:
for _ in range(height):
yield segment
yield line
-
-
-if __name__ == "__main__":
- from rich import print
-
- print(Blank("red"))
diff --git a/src/textual/renderables/gradient.py b/src/textual/renderables/gradient.py
index 7d5fb3243..a0c5379fb 100644
--- a/src/textual/renderables/gradient.py
+++ b/src/textual/renderables/gradient.py
@@ -36,9 +36,3 @@ class VerticalGradient:
),
)
yield Segment(f"{width * ' '}\n", line_color)
-
-
-if __name__ == "__main__":
- from rich import print
-
- print(VerticalGradient("red", "blue"))
diff --git a/src/textual/screen.py b/src/textual/screen.py
index 0069c5a79..2fd22308a 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -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()
diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py
index 707cd67d2..f6d115f8a 100644
--- a/src/textual/scrollbar.py
+++ b/src/textual/scrollbar.py
@@ -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))
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 8d993453c..fcc8aa328 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -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.
diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py
index 5cbc3f94d..533e0ba86 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -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",
diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi
index 0169a3b6b..2c6cb36d4 100644
--- a/src/textual/widgets/__init__.pyi
+++ b/src/textual/widgets/__init__.pyi
@@ -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
diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py
index b394762d8..719cbcb0d 100644
--- a/src/textual/widgets/_button.py
+++ b/src/textual/widgets/_button.py
@@ -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.
diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py
index fe39d8b5a..fd8353ab6 100644
--- a/src/textual/widgets/_footer.py
+++ b/src/textual/widgets/_footer.py
@@ -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),
diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py
index c82e1074c..f01587120 100644
--- a/src/textual/widgets/_input.py
+++ b/src/textual/widgets/_input.py
@@ -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)
diff --git a/src/textual/widgets/_label.py b/src/textual/widgets/_label.py
new file mode 100644
index 000000000..df519ae4f
--- /dev/null
+++ b/src/textual/widgets/_label.py
@@ -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."""
diff --git a/src/textual/widgets/tabs.py b/src/textual/widgets/tabs.py
index 2b814d6a8..3a2af067d 100644
--- a/src/textual/widgets/tabs.py
+++ b/src/textual/widgets/tabs.py
@@ -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.
"""
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index cb6252b33..354a78d0f 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -6166,6 +6166,163 @@
'''
# ---
+# name: test_key_display
+ '''
+
+
+ '''
+# ---
# name: test_layers
'''