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 + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KeyDisplayApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ?  Question  ^q  Quit app  Escape!  Escape  A  Letter A  + + + + + ''' +# --- # name: test_layers ''' diff --git a/tests/snapshot_tests/snapshot_apps/key_display.py b/tests/snapshot_tests/snapshot_apps/key_display.py new file mode 100644 index 000000000..9762bcff6 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/key_display.py @@ -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() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 95e83c278..f5116cc78 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -118,3 +118,9 @@ def test_css_property(file_name, snap_compare): def test_multiple_css(snap_compare): # Interaction between multiple CSS files and app-level/classvar CSS assert snap_compare("snapshot_apps/multiple_css/multiple_css.py") + + +# --- Other --- + +def test_key_display(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py") diff --git a/tests/test_screens.py b/tests/test_screens.py index 0841faf51..707bad5df 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -11,8 +11,37 @@ skip_py310 = pytest.mark.skipif( ) +async def test_installed_screens(): + class ScreensApp(App): + SCREENS = { + "home": Screen, # Screen type + "one": Screen(), # Screen instance + "two": lambda: Screen() # Callable[[], Screen] + } + + app = ScreensApp() + async with app.run_test() as pilot: + pilot.app.push_screen("home") # Instantiates and pushes the "home" screen + pilot.app.push_screen("one") # Pushes the pre-instantiated "one" screen + pilot.app.push_screen("home") # Pushes the single instance of "home" screen + pilot.app.push_screen("two") # Calls the callable, pushes returned Screen instance + + assert len(app.screen_stack) == 5 + assert app.screen_stack[1] is app.screen_stack[3] + assert app.screen is app.screen_stack[4] + assert isinstance(app.screen, Screen) + assert app.is_screen_installed(app.screen) + + assert pilot.app.pop_screen() + assert pilot.app.pop_screen() + assert pilot.app.pop_screen() + assert pilot.app.pop_screen() + with pytest.raises(ScreenStackError): + pilot.app.pop_screen() + + + @skip_py310 -@pytest.mark.asyncio async def test_screens(): app = App()