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

View File

@@ -1,5 +1,6 @@
# Change Log # Change Log
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) 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`. - It is now possible to `await` a `Widget.remove`.
https://github.com/Textualize/textual/issues/1094 https://github.com/Textualize/textual/issues/1094
- It is now possible to `await` a `DOMQuery.remove`. Note that this changes - 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 https://github.com/Textualize/textual/issues/1094
- Added Pilot.wait_for_animation - Added Pilot.wait_for_animation
- Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 - 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 ### Changed
- Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145 - 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. - 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
- Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026 - 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 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 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 ## [0.4.0] - 2022-11-08

10
docs.md
View File

@@ -1,14 +1,14 @@
# Documentation Workflow # Documentation Workflow
* [Install Hatch](https://hatch.pypa.io/latest/install/) * Ensure you're inside a *Python 3.10+* virtual environment
* Run the live-reload server using `hatch run docs:serve` from the project root * Run the live-reload server using `mkdocs serve` from the project root
* Create new pages by adding new directories and Markdown files inside `docs/*` * Create new pages by adding new directories and Markdown files inside `docs/*`
## Commands ## Commands
- `hatch run docs:serve` - Start the live-reloading docs server. - `mkdocs serve` - Start the live-reloading docs server.
- `hatch run docs:build` - Build the documentation site. - `mkdocs build` - Build the documentation site.
- `hatch run docs:help` - Print help message and exit. - `mkdocs -h` - Print help message and exit.
## Project layout ## 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 name: Will McGugan
description: CEO / code-monkey description: CEO / code-monkey
avatar: https://github.com/willmcgugan.png 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. 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 ```bash
textual run my_app.py:alternative_app textual run my_app.py:alternative_app
@@ -119,6 +119,6 @@ class LogApp(App):
self.log(self.tree) self.log(self.tree)
if __name__ == "__main__": 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 # Static
A widget which displays static content. 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 - [ ] Focusable
- [x] Container - [ ] Container
## Example ## 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" === "Output"
@@ -32,3 +32,4 @@ This widget sends no messages.
## See Also ## See Also
* [Static](../api/static.md) code reference * [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.app import App, ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widget import Widget 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.css.query import DOMQuery
from textual.reactive import reactive from textual.reactive import reactive
from textual.binding import Binding from textual.binding import Binding
@@ -33,10 +33,10 @@ class Help(Screen):
Returns: Returns:
ComposeResult: The result of composing the help screen. 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.""" """Widget to tell the user they have won."""
MIN_MOVES: Final = 14 MIN_MOVES: Final = 14
@@ -91,9 +91,9 @@ class GameHeader(Widget):
ComposeResult: The result of composing the game header. ComposeResult: The result of composing the game header.
""" """
yield Horizontal( yield Horizontal(
Static(self.app.title, id="app-title"), Label(self.app.title, id="app-title"),
Static(id="moves"), Label(id="moves"),
Static(id="progress"), Label(id="progress"),
) )
def watch_moves(self, moves: int): def watch_moves(self, moves: int):
@@ -102,7 +102,7 @@ class GameHeader(Widget):
Args: Args:
moves (int): The number of moves made. 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): def watch_filled(self, filled: int):
"""Watch the on-count reactive and update when it changes. """Watch the on-count reactive and update when it changes.
@@ -110,7 +110,7 @@ class GameHeader(Widget):
Args: Args:
filled (int): The number of cells that are currently on. 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): class GameCell(Button):
@@ -311,7 +311,7 @@ class FiveByFive(App[None]):
CSS_PATH = "five_by_five.css" CSS_PATH = "five_by_five.css"
"""The name of the stylesheet for the app.""" """The name of the stylesheet for the app."""
SCREENS = {"help": Help()} SCREENS = {"help": Help}
"""The pre-loaded screens for the application.""" """The pre-loaded screens for the application."""
BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")] BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")]

View File

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

170
poetry.lock generated
View File

@@ -31,6 +31,24 @@ python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
frozenlist = ">=1.1.0" 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]] [[package]]
name = "async-timeout" name = "async-timeout"
version = "4.0.2" 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]] [[package]]
name = "colored" name = "colored"
version = "1.4.3" version = "1.4.4"
description = "Simple library for color and formatting to terminal" description = "Simple library for color and formatting to terminal"
category = "dev" category = "dev"
optional = false optional = false
@@ -182,7 +200,7 @@ python-versions = "*"
[[package]] [[package]]
name = "exceptiongroup" name = "exceptiongroup"
version = "1.0.1" version = "1.0.4"
description = "Backport of PEP 654 (exception groups)" description = "Backport of PEP 654 (exception groups)"
category = "dev" category = "dev"
optional = false optional = false
@@ -250,7 +268,7 @@ typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""
[[package]] [[package]]
name = "griffe" 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." description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
category = "dev" category = "dev"
optional = false optional = false
@@ -258,10 +276,60 @@ python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
cached-property = {version = "*", markers = "python_version < \"3.8\""} cached-property = {version = "*", markers = "python_version < \"3.8\""}
colorama = ">=0.4"
[package.extras] [package.extras]
async = ["aiofiles (>=0.7,<1.0)"] 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]] [[package]]
name = "identify" name = "identify"
version = "2.5.8" version = "2.5.8"
@@ -390,7 +458,7 @@ mkdocs = ">=1.1"
[[package]] [[package]]
name = "mkdocs-material" name = "mkdocs-material"
version = "8.5.9" version = "8.5.10"
description = "Documentation that simply works" description = "Documentation that simply works"
category = "dev" category = "dev"
optional = false optional = false
@@ -455,14 +523,14 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
[[package]] [[package]]
name = "mkdocstrings-python" name = "mkdocstrings-python"
version = "0.7.1" version = "0.8.0"
description = "A Python handler for mkdocstrings." description = "A Python handler for mkdocstrings."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
griffe = ">=0.11.1" griffe = ">=0.24"
mkdocstrings = ">=0.19" mkdocstrings = ">=0.19"
[[package]] [[package]]
@@ -541,7 +609,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "0.10.1" version = "0.10.2"
description = "Utility library for gitignore style pattern matching of file paths." description = "Utility library for gitignore style pattern matching of file paths."
category = "dev" category = "dev"
optional = false optional = false
@@ -549,7 +617,7 @@ python-versions = ">=3.7"
[[package]] [[package]]
name = "platformdirs" 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\"." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev" category = "dev"
optional = false optional = false
@@ -663,7 +731,7 @@ testing = ["coverage (==6.2)", "mypy (==0.931)"]
[[package]] [[package]]
name = "pytest-asyncio" name = "pytest-asyncio"
version = "0.20.1" version = "0.20.2"
description = "Pytest support for asyncio" description = "Pytest support for asyncio"
category = "dev" category = "dev"
optional = false optional = false
@@ -748,6 +816,20 @@ urllib3 = ">=1.21.1,<1.27"
socks = ["PySocks (>=1.5.6,!=1.5.7)"] socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 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]] [[package]]
name = "rich" name = "rich"
version = "12.6.0" version = "12.6.0"
@@ -793,6 +875,14 @@ category = "dev"
optional = false optional = false
python-versions = ">=3.6" 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]] [[package]]
name = "syrupy" name = "syrupy"
version = "3.0.5" version = "3.0.5"
@@ -871,7 +961,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.16.6" version = "20.16.7"
description = "Virtual Python Environment builder" description = "Virtual Python Environment builder"
category = "dev" category = "dev"
optional = false optional = false
@@ -929,7 +1019,7 @@ dev = ["aiohttp", "click", "msgpack"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.7" python-versions = "^3.7"
content-hash = "0c33f462f79d31068c0d74743f47c1d713ecc8ba009eaef1bef832f1272e4b1a" content-hash = "578d7f611a797d406b8db7c61f28796e81af2e637d9671caab9b4ea2b1cf93c6"
[metadata.files] [metadata.files]
aiohttp = [ aiohttp = [
@@ -1025,6 +1115,10 @@ aiosignal = [
{file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
{file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, {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 = [ async-timeout = [
{file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
{file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, {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"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
colored = [ colored = [
{file = "colored-1.4.3.tar.gz", hash = "sha256:b7b48b9f40e8a65bbb54813d5d79dd008dc8b8c5638d5bbfd30fc5a82e6def7a"}, {file = "colored-1.4.4.tar.gz", hash = "sha256:04ff4d4dd514274fe3b99a21bb52fb96f2688c01e93fba7bef37221e7cb56ce0"},
] ]
commonmark = [ commonmark = [
{file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, {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"}, {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"},
] ]
exceptiongroup = [ exceptiongroup = [
{file = "exceptiongroup-1.0.1-py3-none-any.whl", hash = "sha256:4d6c0aa6dd825810941c792f53d7b8d71da26f5e5f84f20f9508e8f2d33b140a"}, {file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"},
{file = "exceptiongroup-1.0.1.tar.gz", hash = "sha256:73866f7f842ede6cb1daa42c4af078e2035e5f7607f0e2c762cc51bb31bbe7b2"}, {file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"},
] ]
filelock = [ filelock = [
{file = "filelock-3.8.0-py3-none-any.whl", hash = "sha256:617eb4e5eedc82fc5f47b6d61e4d11cb837c56cb4544e39081099fa17ad109d4"}, {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"}, {file = "GitPython-3.1.29.tar.gz", hash = "sha256:cc36bfc4a3f913e66805a28e84703e419d9c264c1077e537b54f0e1af85dbefd"},
] ]
griffe = [ griffe = [
{file = "griffe-0.23.0-py3-none-any.whl", hash = "sha256:cfca5f523808109da3f8cfaa46e325fa2e5bef51120d1146e908c121b56475f0"}, {file = "griffe-0.24.0-py3-none-any.whl", hash = "sha256:6c6b64716155f27ef63377e2b04749079c359f06d9a6e638bb2f885cbe463360"},
{file = "griffe-0.23.0.tar.gz", hash = "sha256:a639e2968c8e27f56ebcc57f869a03cea7ac7e7f5684bd2429c665f761c4e7bd"}, {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 = [ identify = [
{file = "identify-2.5.8-py2.py3-none-any.whl", hash = "sha256:48b7925fe122720088aeb7a6c34f17b27e706b72c61070f27fe3789094233440"}, {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"}, {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"},
] ]
mkdocs-material = [ mkdocs-material = [
{file = "mkdocs_material-8.5.9-py3-none-any.whl", hash = "sha256:143ea55843b3747b640e1110824d91e8a4c670352380e166e64959f9abe98862"}, {file = "mkdocs_material-8.5.10-py3-none-any.whl", hash = "sha256:51760fa4c9ee3ca0b3a661ec9f9817ec312961bb84ff19e5b523fdc5256e1d6c"},
{file = "mkdocs_material-8.5.9.tar.gz", hash = "sha256:45eeabb23d2caba8fa3b85c91d9ec8e8b22add716e9bba8faf16d56af8aa5622"}, {file = "mkdocs_material-8.5.10.tar.gz", hash = "sha256:7623608f746c6d9ff68a8ef01f13eddf32fa2cae5e15badb251f26d1196bc8f1"},
] ]
mkdocs-material-extensions = [ mkdocs-material-extensions = [
{file = "mkdocs_material_extensions-1.1-py3-none-any.whl", hash = "sha256:bcc2e5fc70c0ec50e59703ee6e639d87c7e664c0c441c014ea84461a90f1e902"}, {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"}, {file = "mkdocstrings-0.19.0.tar.gz", hash = "sha256:efa34a67bad11229d532d89f6836a8a215937548623b64f3698a1df62e01cc3e"},
] ]
mkdocstrings-python = [ mkdocstrings-python = [
{file = "mkdocstrings-python-0.7.1.tar.gz", hash = "sha256:c334b382dca202dfa37071c182418a6df5818356a95d54362a2b24822ca3af71"}, {file = "mkdocstrings-python-0.8.0.tar.gz", hash = "sha256:67f674a8b252fca0b9411c10fb923dd6aacc49ac55c59f738b78b06592ace43d"},
{file = "mkdocstrings_python-0.7.1-py3-none-any.whl", hash = "sha256:a22060bfa374697678e9af4e62b020d990dad2711c98f7a9fac5c0345bef93c7"}, {file = "mkdocstrings_python-0.8.0-py3-none-any.whl", hash = "sha256:cbee42e53aeaae340d79d72e9bcf42f2b6abe4d11696597c76e3e86a4d9f05a0"},
] ]
msgpack = [ msgpack = [
{file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"}, {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"}, {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
] ]
pathspec = [ pathspec = [
{file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, {file = "pathspec-0.10.2-py3-none-any.whl", hash = "sha256:88c2606f2c1e818b978540f73ecc908e13999c6c3a383daf3705652ae79807a5"},
{file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, {file = "pathspec-0.10.2.tar.gz", hash = "sha256:8f6bf73e5758fd365ef5d58ce09ac7c27d2833a8d7da51712eac6e27e35141b0"},
] ]
platformdirs = [ platformdirs = [
{file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"}, {file = "platformdirs-2.5.4-py3-none-any.whl", hash = "sha256:af0276409f9a02373d540bf8480021a048711d572745aef4b7842dad245eba10"},
{file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"}, {file = "platformdirs-2.5.4.tar.gz", hash = "sha256:1006647646d80f16130f052404c6b901e80ee4ed6bef6792e1f238a8969106f7"},
] ]
pluggy = [ pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {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"}, {file = "pytest_aiohttp-1.0.4-py3-none-any.whl", hash = "sha256:1d2dc3a304c2be1fd496c0c2fb6b31ab60cd9fc33984f761f951f8ea1eb4ca95"},
] ]
pytest-asyncio = [ pytest-asyncio = [
{file = "pytest-asyncio-0.20.1.tar.gz", hash = "sha256:626699de2a747611f3eeb64168b3575f70439b06c3d0206e6ceaeeb956e65519"}, {file = "pytest-asyncio-0.20.2.tar.gz", hash = "sha256:32a87a9836298a881c0ec637ebcc952cfe23a56436bdc0d09d1511941dd8a812"},
{file = "pytest_asyncio-0.20.1-py3-none-any.whl", hash = "sha256:2c85a835df33fda40fe3973b451e0c194ca11bc2c007eabff90bb3d156fc172b"}, {file = "pytest_asyncio-0.20.2-py3-none-any.whl", hash = "sha256:07e0abf9e6e6b95894a39f688a4a875d63c2128f76c02d03d16ccbc35bcc0f8a"},
] ]
pytest-cov = [ pytest-cov = [
{file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, {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-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"},
{file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, {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 = [ rich = [
{file = "rich-12.6.0-py3-none-any.whl", hash = "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e"}, {file = "rich-12.6.0-py3-none-any.whl", hash = "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e"},
{file = "rich-12.6.0.tar.gz", hash = "sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"}, {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-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"},
{file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, {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 = [ syrupy = [
{file = "syrupy-3.0.5-py3-none-any.whl", hash = "sha256:6dc0472cc690782b517d509a2854b5ca552a9851acf43b8a847cfa1aed6f6b01"}, {file = "syrupy-3.0.5-py3-none-any.whl", hash = "sha256:6dc0472cc690782b517d509a2854b5ca552a9851acf43b8a847cfa1aed6f6b01"},
{file = "syrupy-3.0.5.tar.gz", hash = "sha256:928962a6c14abb2695be9a49b42a016a4e4abdb017a69104cde958f2faf01f98"}, {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"}, {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"},
] ]
virtualenv = [ virtualenv = [
{file = "virtualenv-20.16.6-py3-none-any.whl", hash = "sha256:186ca84254abcbde98180fd17092f9628c5fe742273c02724972a1d8a2035108"}, {file = "virtualenv-20.16.7-py3-none-any.whl", hash = "sha256:efd66b00386fdb7dbe4822d172303f40cd05e50e01740b19ea42425cbe653e29"},
{file = "virtualenv-20.16.6.tar.gz", hash = "sha256:530b850b523c6449406dfba859d6345e48ef19b8439606c5d74d7d3c9e14d76e"}, {file = "virtualenv-20.16.7.tar.gz", hash = "sha256:8691e3ff9387f743e00f6bb20f70121f5e4f596cae754531f2b3b3a1b1ac696e"},
] ]
watchdog = [ watchdog = [
{file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"}, {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"},

View File

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

View File

@@ -18,6 +18,7 @@ from time import perf_counter
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
Callable,
Generic, Generic,
Iterable, Iterable,
List, List,
@@ -44,7 +45,8 @@ from ._context import active_app
from ._event_broker import NoHandler, extract_handler_actions from ._event_broker import NoHandler, extract_handler_actions
from ._filter import LineFilter, Monochrome from ._filter import LineFilter, Monochrome
from ._path import _make_path_object_relative 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 .binding import Binding, Bindings
from .css.query import NoMatches from .css.query import NoMatches
from .css.stylesheet import Stylesheet from .css.stylesheet import Stylesheet
@@ -55,12 +57,13 @@ from .drivers.headless_driver import HeadlessDriver
from .features import FeatureFlag, parse_features from .features import FeatureFlag, parse_features
from .file_monitor import FileMonitor from .file_monitor import FileMonitor
from .geometry import Offset, Region, Size from .geometry import Offset, Region, Size
from .keys import REPLACED_KEYS from .keys import REPLACED_KEYS, _get_key_display
from .messages import CallbackType from .messages import CallbackType
from .reactive import Reactive from .reactive import Reactive
from .renderables.blank import Blank from .renderables.blank import Blank
from .screen import Screen from .screen import Screen
from .widget import AwaitMount, Widget, MountError from .widget import AwaitMount, MountError, Widget
if TYPE_CHECKING: if TYPE_CHECKING:
from .devtools.client import DevtoolsClient from .devtools.client import DevtoolsClient
@@ -101,7 +104,6 @@ DEFAULT_COLORS = {
ComposeResult = Iterable[Widget] ComposeResult = Iterable[Widget]
RenderResult = RenderableType RenderResult = RenderableType
AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]" 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 _BASE_PATH: str | None = None
CSS_PATH: CSSPathType = None CSS_PATH: CSSPathType = None
TITLE: str | None = None TITLE: str | None = None
@@ -332,7 +334,7 @@ class App(Generic[ReturnType], DOMNode):
self._registry: WeakSet[DOMNode] = WeakSet() self._registry: WeakSet[DOMNode] = WeakSet()
self._installed_screens: WeakValueDictionary[ self._installed_screens: WeakValueDictionary[
str, Screen str, Screen | Callable[[], Screen]
] = WeakValueDictionary() ] = WeakValueDictionary()
self._installed_screens.update(**self.SCREENS) self._installed_screens.update(**self.SCREENS)
@@ -354,6 +356,7 @@ class App(Generic[ReturnType], DOMNode):
else None else None
) )
self._screenshot: str | None = None self._screenshot: str | None = None
self._dom_lock = asyncio.Lock()
@property @property
def return_value(self) -> ReturnType | None: def return_value(self) -> ReturnType | None:
@@ -669,6 +672,22 @@ class App(Generic[ReturnType], DOMNode):
keys, action, description, show=show, key_display=key_display 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: async def _press_keys(self, keys: Iterable[str]) -> None:
"""A task to send key events.""" """A task to send key events."""
app = self app = self
@@ -706,7 +725,7 @@ class App(Generic[ReturnType], DOMNode):
# This conditional sleep can be removed after that issue is closed. # This conditional sleep can be removed after that issue is closed.
if key == "tab": if key == "tab":
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
await asyncio.sleep(0.02) await asyncio.sleep(0.025)
await app._animator.wait_for_idle() await app._animator.wait_for_idle()
@asynccontextmanager @asynccontextmanager
@@ -1000,12 +1019,15 @@ class App(Generic[ReturnType], DOMNode):
next_screen = self._installed_screens[screen] next_screen = self._installed_screens[screen]
except KeyError: except KeyError:
raise KeyError(f"No screen called {screen!r} installed") from None 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: else:
next_screen = screen next_screen = screen
return next_screen return next_screen
def _get_screen(self, screen: Screen | str) -> tuple[Screen, AwaitMount]: 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. 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 # Close pre-defined screens
for screen in self.SCREENS.values(): for screen in self.SCREENS.values():
if screen._running: if isinstance(screen, Screen) and screen._running:
await self._prune_node(screen) await self._prune_node(screen)
# Close any remaining nodes # Close any remaining nodes
@@ -1938,6 +1960,48 @@ class App(Generic[ReturnType], DOMNode):
for child in widget.children: for child in widget.children:
push(child) 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: async def _prune_node(self, root: Widget) -> None:
"""Remove a node and its children. Children are removed before parents. """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 f"{lower}_{upper.lower()}"
return _re_snake.sub(repl, name).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.app import App, ComposeResult
from textual.constants import BORDERS from textual.constants import BORDERS
from textual.widgets import Button, Static from textual.widgets import Button, Label
from textual.containers import Vertical from textual.containers import Vertical
@@ -48,7 +48,7 @@ class BorderApp(App):
def compose(self): def compose(self):
yield BorderButtons() yield BorderButtons()
self.text = Static(TEXT, id="text") self.text = Label(TEXT, id="text")
yield self.text yield self.text
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:

View File

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

View File

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

View File

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

View File

@@ -366,26 +366,3 @@ def parse(
is_default_rules=is_default_rules, is_default_rules=is_default_rules,
tie_breaker=tie_breaker, 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: Returns:
AwaitRemove: An awaitable object that waits for the widgets to be removed. AwaitRemove: An awaitable object that waits for the widgets to be removed.
""" """
prune_finished_event = asyncio.Event()
app = active_app.get() app = active_app.get()
app.post_message_no_wait( await_remove = app._remove_nodes(list(self))
events.Prune( return await_remove
app,
widgets=app._detach_from_dom(list(self)),
finished_flag=prune_finished_event,
)
)
return AwaitRemove(prune_finished_event)
def set_styles( def set_styles(
self, css: str | None = None, **update_styles self, css: str | None = None, **update_styles

View File

@@ -383,10 +383,3 @@ def percentage_string_to_float(string: str) -> float:
else: else:
float_percentage = float(string) float_percentage = float(string)
return float_percentage 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): class Styles(StylesBase):
node: DOMNode | None = None node: DOMNode | None = None
_rules: RulesMap = field(default_factory=dict) _rules: RulesMap = field(default_factory=dict)
_updates: int = 0
important: set[str] = field(default_factory=set) important: set[str] = field(default_factory=set)
@@ -577,6 +578,7 @@ class Styles(StylesBase):
Returns: Returns:
bool: ``True`` if a rule was cleared, or ``False`` if it was already not set. 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 return self._rules.pop(rule, None) is not None
def get_rules(self) -> RulesMap: def get_rules(self) -> RulesMap:
@@ -592,6 +594,7 @@ class Styles(StylesBase):
Returns: Returns:
bool: ``True`` if the rule changed, otherwise ``False``. bool: ``True`` if the rule changed, otherwise ``False``.
""" """
self._updates += 1
if value is None: if value is None:
return self._rules.pop(rule, None) is not None return self._rules.pop(rule, None) is not None
current = self._rules.get(rule) current = self._rules.get(rule)
@@ -610,6 +613,7 @@ class Styles(StylesBase):
def reset(self) -> None: def reset(self) -> None:
"""Reset the rules to initial state.""" """Reset the rules to initial state."""
self._updates += 1
self._rules.clear() self._rules.clear()
def merge(self, other: Styles) -> None: def merge(self, other: Styles) -> None:
@@ -618,10 +622,11 @@ class Styles(StylesBase):
Args: Args:
other (Styles): A Styles object. other (Styles): A Styles object.
""" """
self._updates += 1
self._rules.update(other._rules) self._rules.update(other._rules)
def merge_rules(self, rules: RulesMap) -> None: def merge_rules(self, rules: RulesMap) -> None:
self._updates += 1
self._rules.update(rules) self._rules.update(rules)
def extract_rules( def extract_rules(
@@ -929,6 +934,18 @@ class RenderStyles(StylesBase):
self._base_styles = base self._base_styles = base
self._inline_styles = inline_styles self._inline_styles = inline_styles
self._animate: BoundAnimator | None = None 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 @property
def base(self) -> Styles: def base(self) -> Styles:
@@ -946,6 +963,21 @@ class RenderStyles(StylesBase):
assert self.node is not None assert self.node is not None
return self.node.rich_style 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( def animate(
self, self,
attribute: str, attribute: str,
@@ -972,6 +1004,7 @@ class RenderStyles(StylesBase):
""" """
if self._animate is None: if self._animate is None:
assert self.node is not None
self._animate = self.node.app.animator.bind(self) self._animate = self.node.app.animator.bind(self)
assert self._animate is not None assert self._animate is not None
self._animate( self._animate(
@@ -1003,16 +1036,19 @@ class RenderStyles(StylesBase):
def merge_rules(self, rules: RulesMap) -> None: def merge_rules(self, rules: RulesMap) -> None:
self._inline_styles.merge_rules(rules) self._inline_styles.merge_rules(rules)
self._updates += 1
def reset(self) -> None: def reset(self) -> None:
"""Reset the rules to initial state.""" """Reset the rules to initial state."""
self._inline_styles.reset() self._inline_styles.reset()
self._updates += 1
def has_rule(self, rule: str) -> bool: def has_rule(self, rule: str) -> bool:
"""Check if a rule has been set.""" """Check if a rule has been set."""
return self._inline_styles.has_rule(rule) or self._base_styles.has_rule(rule) return self._inline_styles.has_rule(rule) or self._base_styles.has_rule(rule)
def set_rule(self, rule: str, value: object | None) -> bool: def set_rule(self, rule: str, value: object | None) -> bool:
self._updates += 1
return self._inline_styles.set_rule(rule, value) return self._inline_styles.set_rule(rule, value)
def get_rule(self, rule: str, default: object = None) -> object: 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: def clear_rule(self, rule_name: str) -> bool:
"""Clear a rule (from inline).""" """Clear a rule (from inline)."""
self._updates += 1
return self._inline_styles.clear_rule(rule_name) return self._inline_styles.clear_rule(rule_name)
def get_rules(self) -> RulesMap: def get_rules(self) -> RulesMap:
@@ -1037,25 +1074,3 @@ class RenderStyles(StylesBase):
styles.merge(self._inline_styles) styles.merge(self._inline_styles)
combined_css = styles.css combined_css = styles.css
return combined_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() name: list(tokenize_value(value, "__name__")) for name, value in values.items()
} }
return value_tokens 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_column("Dark", justify="center")
table.add_row(make_shades(light), make_shades(dark)) table.add_row(make_shades(light), make_shades(dark))
return table 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: finally:
with timer("selector.close"): with timer("selector.close"):
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.""" """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): class Show(Event, bubble=False):
"""Sent when a widget has become visible.""" """Sent when a widget has become visible."""

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import unicodedata
from enum import Enum from enum import Enum
@@ -219,7 +220,34 @@ KEY_ALIASES = {
"ctrl+j": ["newline"], "ctrl+j": ["newline"],
} }
KEY_DISPLAY_ALIASES = {
"up": "",
"down": "",
"left": "",
"right": "",
"backspace": "",
"escape": "ESC",
"enter": "",
}
def _get_key_aliases(key: str) -> list[str]: def _get_key_aliases(key: str) -> list[str]:
"""Return all aliases for the given key, including the key itself""" """Return all aliases for the given key, including the key itself"""
return [key] + KEY_ALIASES.get(key, []) 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. layout (bool, optional): Perform a layout on change. Defaults to False.
repaint (bool, optional): Perform a repaint on change. Defaults to True. repaint (bool, optional): Perform a repaint on change. Defaults to True.
init (bool, optional): Call watchers on initialize (post mount). Defaults to False. 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__( def __init__(
@@ -76,7 +76,7 @@ class Reactive(Generic[ReactiveType]):
default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default. default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default.
layout (bool, optional): Perform a layout on change. Defaults to False. layout (bool, optional): Perform a layout on change. Defaults to False.
repaint (bool, optional): Perform a repaint on change. Defaults to True. 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: Returns:
Reactive: A Reactive instance which calls watchers or initialize. 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. layout (bool, optional): Perform a layout on change. Defaults to False.
repaint (bool, optional): Perform a repaint on change. Defaults to True. repaint (bool, optional): Perform a repaint on change. Defaults to True.
init (bool, optional): Call watchers on initialize (post mount). 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__( def __init__(

View File

@@ -25,9 +25,3 @@ class Blank:
for _ in range(height): for _ in range(height):
yield segment yield segment
yield line 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) 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) # Check for any widgets marked as 'dirty' (needs a repaint)
event.prevent_default() event.prevent_default()
if self.is_current: async with self.app._dom_lock:
if self._layout_required: if self.is_current:
self._refresh_layout() if self._layout_required:
self._layout_required = False self._refresh_layout()
self._dirty_widgets.clear() self._layout_required = False
if self._repaint_required: self._dirty_widgets.clear()
self._dirty_widgets.clear() if self._repaint_required:
self._dirty_widgets.add(self) self._dirty_widgets.clear()
self._repaint_required = False self._dirty_widgets.add(self)
self._repaint_required = False
if self._dirty_widgets: if self._dirty_widgets:
self.update_timer.resume() self.update_timer.resume()
# The Screen is idle - a good opportunity to invoke the scheduled callbacks # The Screen is idle - a good opportunity to invoke the scheduled callbacks
await self._invoke_and_clear_callbacks() await self._invoke_and_clear_callbacks()

View File

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

View File

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

View File

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

View File

@@ -168,9 +168,9 @@ class Button(Static, can_focus=True):
"""Create a Button widget. """Create a Button widget.
Args: Args:
label (str): The text that appears within the button. label (str, optional): The text that appears within the button.
disabled (bool): Whether the button is disabled or not. disabled (bool, optional): Whether the button is disabled or not.
variant (ButtonVariant): The variant of the button. variant (ButtonVariant, optional): The variant of the button.
name (str | None, optional): The name of the button. name (str | None, optional): The name of the button.
id (str | None, optional): The ID of the button in the DOM. id (str | None, optional): The ID of the button in the DOM.
classes (str | None, optional): The CSS classes of the button. classes (str | None, optional): The CSS classes of the button.
@@ -186,7 +186,7 @@ class Button(Static, can_focus=True):
if disabled: if disabled:
self.add_class("-disabled") self.add_class("-disabled")
self.variant = variant self.variant = self.validate_variant(variant)
label: Reactive[RenderableType] = Reactive("") label: Reactive[RenderableType] = Reactive("")
variant = Reactive.init("default") variant = Reactive.init("default")
@@ -267,8 +267,8 @@ class Button(Static, can_focus=True):
"""Utility constructor for creating a success Button variant. """Utility constructor for creating a success Button variant.
Args: Args:
label (str): The text that appears within the button. label (str, optional): The text that appears within the button.
disabled (bool): Whether the button is disabled or not. disabled (bool, optional): Whether the button is disabled or not.
name (str | None, optional): The name of the button. name (str | None, optional): The name of the button.
id (str | None, optional): The ID of the button in the DOM. id (str | None, optional): The ID of the button in the DOM.
classes(str | None, optional): The CSS classes of the button. 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. """Utility constructor for creating a warning Button variant.
Args: Args:
label (str): The text that appears within the button. label (str, optional): The text that appears within the button.
disabled (bool): Whether the button is disabled or not. disabled (bool, optional): Whether the button is disabled or not.
name (str | None, optional): The name of the button. name (str | None, optional): The name of the button.
id (str | None, optional): The ID of the button in the DOM. id (str | None, optional): The ID of the button in the DOM.
classes (str | None, optional): The CSS classes of the button. 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. """Utility constructor for creating an error Button variant.
Args: Args:
label (str): The text that appears within the button. label (str, optional): The text that appears within the button.
disabled (bool): Whether the button is disabled or not. disabled (bool, optional): Whether the button is disabled or not.
name (str | None, optional): The name of the button. name (str | None, optional): The name of the button.
id (str | None, optional): The ID of the button in the DOM. id (str | None, optional): The ID of the button in the DOM.
classes (str | None, optional): The CSS classes of the button. 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 rich.text import Text
from .. import events from .. import events
from ..keys import _get_key_display
from ..reactive import Reactive, watch from ..reactive import Reactive, watch
from ..widget import Widget from ..widget import Widget
@@ -99,11 +100,12 @@ class Footer(Widget):
for action, bindings in action_to_bindings.items(): for action, bindings in action_to_bindings.items():
binding = bindings[0] binding = bindings[0]
key_display = ( if binding.key_display is None:
binding.key.upper() key_display = self.app.get_key_display(binding.key)
if binding.key_display is None if key_display is None:
else binding.key_display key_display = binding.key.upper()
) else:
key_display = binding.key_display
hovered = self.highlight_key == binding.key hovered = self.highlight_key == binding.key
key_text = Text.assemble( key_text = Text.assemble(
(f" {key_display} ", highlight_key_style if hovered else key_style), (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: if self.has_focus:
cursor_style = self.get_component_rich_style("input--cursor") cursor_style = self.get_component_rich_style("input--cursor")
if self._cursor_visible: 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) placeholder.stylize(cursor_style, 0, 1)
return placeholder return placeholder
return _InputRenderable(self, self._cursor_visible) 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: Args:
tabs (list[Tab]): A list of Tab objects defining the tabs which should be rendered. 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 (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_tab_style (StyleType, optional): Style to apply to the label of the active tab.
active_bar_style (StyleType): Style to apply to the underline of the active tab. active_bar_style (StyleType, optional): Style to apply to the underline of the active tab.
inactive_tab_style (StyleType): Style to apply to the label of inactive tabs. inactive_tab_style (StyleType, optional): Style to apply to the label of inactive tabs.
inactive_bar_style (StyleType): Style to apply to the underline of inactive tabs. inactive_bar_style (StyleType, optional): Style to apply to the underline of inactive tabs.
inactive_text_opacity (float): Opacity of the text labels of inactive tabs. inactive_text_opacity (float, optional): Opacity of the text labels of inactive tabs.
animation_duration (float): The duration of the tab change animation, in seconds. animation_duration (float, optional): The duration of the tab change animation, in seconds.
animation_function (str): The easing function to use for the tab change animation. 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 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. 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 will activate the next tab (in left-to-right order) with a label starting with
that character. 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 # name: test_layers
''' '''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg"> <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): def test_multiple_css(snap_compare):
# Interaction between multiple CSS files and app-level/classvar CSS # Interaction between multiple CSS files and app-level/classvar CSS
assert snap_compare("snapshot_apps/multiple_css/multiple_css.py") 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 @skip_py310
@pytest.mark.asyncio
async def test_screens(): async def test_screens():
app = App() app = App()