diff --git a/CHANGELOG.md b/CHANGELOG.md index 629076750e..c1d5929535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `DataTable.remove_column` method https://github.com/Textualize/textual/pull/2899 +- Added notifications https://github.com/Textualize/textual/pull/2866 ### Fixed @@ -26,9 +27,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Updated `DataTable.get_cell` type hints to accept string keys https://github.com/Textualize/textual/issues/2586 -- Added `DataTable.get_cell_coordinate` method +- Added `DataTable.get_cell_coordinate` method - Added `DataTable.get_row_index` method https://github.com/Textualize/textual/issues/2587 -- Added `DataTable.get_column_index` method +- Added `DataTable.get_column_index` method - Added can-focus pseudo-class to target widgets that may receive focus - Make `Markdown.update` optionally awaitable https://github.com/Textualize/textual/pull/2838 - Added `default` parameter to `DataTable.add_column` for populating existing rows https://github.com/Textualize/textual/pull/2836 diff --git a/docs/examples/widgets/toast.py b/docs/examples/widgets/toast.py new file mode 100644 index 0000000000..b74bfaa382 --- /dev/null +++ b/docs/examples/widgets/toast.py @@ -0,0 +1,26 @@ +from textual.app import App + + +class ToastApp(App[None]): + def on_mount(self) -> None: + # Show an information notification. + self.notify("It's an older code, sir, but it checks out.") + + # Show a warning. Note that Textual's notification system allows + # for the use of Rich console markup. + self.notify( + "Now witness the firepower of this fully " + "[b]ARMED[/b] and [i][b]OPERATIONAL[/b][/i] battle station!", + title="Possible trap detected", + severity="warning", + ) + + # Show an error. Set a longer timeout so it's noticed. + self.notify("It's a trap!", severity="error", timeout=10) + + # Show an information notification, but without any sort of title. + self.notify("It's against my programming to impersonate a deity.", title="") + + +if __name__ == "__main__": + ToastApp().run() diff --git a/docs/widgets/toast.md b/docs/widgets/toast.md new file mode 100644 index 0000000000..be7ec2f7cb --- /dev/null +++ b/docs/widgets/toast.md @@ -0,0 +1,79 @@ +# Toast + +!!! tip "Added in version 0.30.0" + +A widget which displays a notification message. + +- [ ] Focusable +- [ ] Container + +Note that `Toast` isn't designed to be used directly in your applications, +but it is instead used by [`notify`][textual.app.App.notify] to +display a message when using Textual's built-in notification system. + +## Styling + +You can customize the style of Toasts by targeting the `Toast` [CSS type](/guide/CSS/#type-selector). +For example: + + +```scss +Toast { + padding: 3; +} +``` + +The three severity levels also have corresponding +[classes](/guide/CSS/#class-name-selector), allowing you to target the +different styles of notification. They are: + +- `-information` +- `-warning` +- `-error` + +If you wish to tailor the notifications for your application you can add +rules to your CSS like this: + +```scss +Toast.-information { + /* Styling here. */ +} + +Toast.-warning { + /* Styling here. */ +} + +Toast.-error { + /* Styling here. */ +} +``` + +You can customize just the title wih the `toast--title` class. +The following would make the title italic for an information toast: + +```scss +Toast.-information .toast--title { + text-style: italic; +} + +``` + +## Example + +=== "Output" + + ```{.textual path="docs/examples/widgets/toast.py"} + ``` + +=== "toast.py" + + ```python + --8<-- "docs/examples/widgets/toast.py" + ``` + +--- + +::: textual.widgets._toast + options: + show_root_heading: true + show_root_toc_entry: true diff --git a/poetry.lock b/poetry.lock index a534d52afe..9e21e11bd6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,9 +1,10 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. [[package]] name = "aiohttp" version = "3.8.4" description = "Async http client/server framework (asyncio)" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -114,6 +115,7 @@ speedups = ["Brotli", "aiodns", "cchardet"] name = "aiosignal" version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -128,6 +130,7 @@ frozenlist = ">=1.1.0" name = "anyio" version = "3.7.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -150,6 +153,7 @@ trio = ["trio (<0.22)"] name = "async-timeout" version = "4.0.2" description = "Timeout context manager for asyncio programs" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -164,6 +168,7 @@ typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} name = "asynctest" version = "0.13.0" description = "Enhance the standard unittest package with features for testing asyncio libraries" +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -175,6 +180,7 @@ files = [ name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -196,6 +202,7 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte name = "black" version = "23.3.0" description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -246,6 +253,7 @@ uvloop = ["uvloop (>=0.15.2)"] name = "cached-property" version = "1.5.2" description = "A decorator for caching properties in classes." +category = "dev" optional = false python-versions = "*" files = [ @@ -257,6 +265,7 @@ files = [ name = "certifi" version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -268,6 +277,7 @@ files = [ name = "cfgv" version = "3.3.1" description = "Validate configuration and produce human readable error messages." +category = "dev" optional = false python-versions = ">=3.6.1" files = [ @@ -279,6 +289,7 @@ files = [ name = "charset-normalizer" version = "3.2.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" optional = false python-versions = ">=3.7.0" files = [ @@ -361,13 +372,14 @@ files = [ [[package]] name = "click" -version = "8.1.4" +version = "8.1.5" description = "Composable command line interface toolkit" +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "click-8.1.4-py3-none-any.whl", hash = "sha256:2739815aaa5d2c986a88f1e9230c55e17f0caad3d958a5e13ad0797c166db9e3"}, - {file = "click-8.1.4.tar.gz", hash = "sha256:b97d0c74955da062a7d4ef92fadb583806a585b2ea81958a81bd72726cbb8e37"}, + {file = "click-8.1.5-py3-none-any.whl", hash = "sha256:e576aa487d679441d7d30abb87e1b43d24fc53bffb8758443b1a9e1cee504548"}, + {file = "click-8.1.5.tar.gz", hash = "sha256:4be4b1af8d665c6d942909916d31a213a106800c47d0eeba73d34da3cbc11367"}, ] [package.dependencies] @@ -378,6 +390,7 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -389,6 +402,7 @@ files = [ name = "colored" version = "1.4.4" description = "Simple library for color and formatting to terminal" +category = "dev" optional = false python-versions = "*" files = [ @@ -399,6 +413,7 @@ files = [ name = "coverage" version = "7.2.7" description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -469,19 +484,21 @@ toml = ["tomli"] [[package]] name = "distlib" -version = "0.3.6" +version = "0.3.7" description = "Distribution utilities" +category = "dev" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, - {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, + {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, + {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, ] [[package]] name = "exceptiongroup" version = "1.1.2" description = "Backport of PEP 654 (exception groups)" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -496,6 +513,7 @@ test = ["pytest (>=6)"] name = "filelock" version = "3.12.2" description = "A platform independent file lock." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -511,6 +529,7 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p name = "frozenlist" version = "1.3.3" description = "A list-like structure which implements collections.abc.MutableSequence" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -594,6 +613,7 @@ files = [ name = "ghp-import" version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." +category = "dev" optional = false python-versions = "*" files = [ @@ -611,6 +631,7 @@ dev = ["flake8", "markdown", "twine", "wheel"] name = "gitdb" version = "4.0.10" description = "Git Object Database" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -625,6 +646,7 @@ smmap = ">=3.0.1,<6" name = "gitpython" version = "3.1.32" description = "GitPython is a Python library used to interact with Git repositories" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -640,6 +662,7 @@ typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\"" name = "griffe" version = "0.30.1" 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 python-versions = ">=3.7" files = [ @@ -655,6 +678,7 @@ colorama = ">=0.4" 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" files = [ @@ -669,6 +693,7 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""} name = "httpcore" version = "0.16.3" description = "A minimal low-level HTTP client." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -680,16 +705,17 @@ files = [ anyio = ">=3.0,<5.0" certifi = "*" h11 = ">=0.13,<0.15" -sniffio = "==1.*" +sniffio = ">=1.0.0,<2.0.0" [package.extras] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] [[package]] name = "httpx" version = "0.23.3" description = "The next generation HTTP client." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -705,14 +731,15 @@ sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<13)"] +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.*)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] [[package]] name = "identify" version = "2.5.24" description = "File identification library for Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -727,6 +754,7 @@ license = ["ukkonen"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -738,6 +766,7 @@ files = [ name = "importlib-metadata" version = "6.7.0" description = "Read metadata from Python packages" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -758,6 +787,7 @@ testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -769,6 +799,7 @@ files = [ name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -786,6 +817,7 @@ i18n = ["Babel (>=2.7)"] name = "linkify-it-py" version = "2.0.2" description = "Links recognition library with FULL unicode support." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -806,6 +838,7 @@ test = ["coverage", "pytest", "pytest-cov"] name = "markdown" version = "3.3.7" description = "Python implementation of Markdown." +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -823,6 +856,7 @@ testing = ["coverage", "pyyaml"] name = "markdown-it-py" version = "2.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -850,6 +884,7 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "markupsafe" version = "2.1.3" description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -909,6 +944,7 @@ files = [ name = "mdit-py-plugins" version = "0.3.5" description = "Collection of plugins for markdown-it-py" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -928,6 +964,7 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -939,6 +976,7 @@ files = [ name = "mergedeep" version = "1.3.4" description = "A deep merge function for 🐍." +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -950,6 +988,7 @@ files = [ name = "mkdocs" version = "1.4.3" description = "Project documentation with Markdown." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -979,6 +1018,7 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp name = "mkdocs-autorefs" version = "0.4.1" description = "Automatically link across pages in MkDocs." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -994,6 +1034,7 @@ mkdocs = ">=1.1" name = "mkdocs-exclude" version = "1.0.2" description = "A mkdocs plugin that lets you exclude files or trees." +category = "dev" optional = false python-versions = "*" files = [ @@ -1007,6 +1048,7 @@ mkdocs = "*" name = "mkdocs-material" version = "9.1.18" description = "Documentation that simply works" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1029,6 +1071,7 @@ requests = ">=2.26" name = "mkdocs-material-extensions" version = "1.1.1" description = "Extension pack for Python Markdown and MkDocs Material." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1040,6 +1083,7 @@ files = [ name = "mkdocs-rss-plugin" version = "1.5.0" description = "MkDocs plugin which generates a static RSS feed using git log and page.meta." +category = "dev" optional = false python-versions = ">=3.7, <4" files = [ @@ -1050,17 +1094,18 @@ files = [ [package.dependencies] GitPython = ">=3.1,<3.2" mkdocs = ">=1.1,<2" -pytz = {version = "==2022.*", markers = "python_version < \"3.9\""} -tzdata = {version = "==2022.*", markers = "python_version >= \"3.9\" and sys_platform == \"win32\""} +pytz = {version = ">=2022.0.0,<2023.0.0", markers = "python_version < \"3.9\""} +tzdata = {version = ">=2022.0.0,<2023.0.0", markers = "python_version >= \"3.9\" and sys_platform == \"win32\""} [package.extras] -dev = ["black", "feedparser (>=6.0,<6.1)", "flake8 (>=4,<5.1)", "pre-commit (>=2.10,<2.21)", "pytest-cov (==4.0.*)", "validator-collection (>=1.5,<1.6)"] -doc = ["mkdocs-bootswatch (>=1,<2)", "mkdocs-minify-plugin (==0.5.*)", "pygments (>=2.5,<3)", "pymdown-extensions (>=7,<10)"] +dev = ["black", "feedparser (>=6.0,<6.1)", "flake8 (>=4,<5.1)", "pre-commit (>=2.10,<2.21)", "pytest-cov (>=4.0.0,<4.1.0)", "validator-collection (>=1.5,<1.6)"] +doc = ["mkdocs-bootswatch (>=1,<2)", "mkdocs-minify-plugin (>=0.5.0,<0.6.0)", "pygments (>=2.5,<3)", "pymdown-extensions (>=7,<10)"] [[package]] name = "mkdocstrings" version = "0.20.0" description = "Automatic documentation from sources, for MkDocs." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1086,6 +1131,7 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] name = "mkdocstrings-python" version = "0.10.1" description = "A Python handler for mkdocstrings." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1101,6 +1147,7 @@ mkdocstrings = ">=0.20" name = "msgpack" version = "1.0.5" description = "MessagePack serializer" +category = "dev" optional = false python-versions = "*" files = [ @@ -1173,6 +1220,7 @@ files = [ name = "multidict" version = "6.0.4" description = "multidict implementation" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1256,6 +1304,7 @@ files = [ name = "mypy" version = "1.4.1" description = "Optional static typing for Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1303,6 +1352,7 @@ reports = ["lxml"] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1314,6 +1364,7 @@ files = [ name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" +category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -1328,6 +1379,7 @@ setuptools = "*" name = "packaging" version = "23.1" description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1339,6 +1391,7 @@ files = [ name = "pathspec" version = "0.11.1" description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1348,13 +1401,14 @@ files = [ [[package]] name = "platformdirs" -version = "3.8.1" +version = "3.9.1" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.8.1-py3-none-any.whl", hash = "sha256:cec7b889196b9144d088e4c57d9ceef7374f6c39694ad1577a0aab50d27ea28c"}, - {file = "platformdirs-3.8.1.tar.gz", hash = "sha256:f87ca4fcff7d2b0f81c6a748a77973d7af0f4d526f98f308477c3c436c74d528"}, + {file = "platformdirs-3.9.1-py3-none-any.whl", hash = "sha256:ad8291ae0ae5072f66c16945166cb11c63394c7a3ad1b1bc9828ca3162da8c2f"}, + {file = "platformdirs-3.9.1.tar.gz", hash = "sha256:1b42b450ad933e981d56e59f1b97495428c9bd60698baab9f3eb3d00d5822421"}, ] [package.dependencies] @@ -1368,6 +1422,7 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest- name = "pluggy" version = "1.2.0" description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1386,6 +1441,7 @@ testing = ["pytest", "pytest-benchmark"] name = "pre-commit" version = "2.21.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1405,6 +1461,7 @@ virtualenv = ">=20.10.0" name = "pygments" version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1417,13 +1474,14 @@ plugins = ["importlib-metadata"] [[package]] name = "pymdown-extensions" -version = "10.0.1" +version = "10.1" description = "Extension pack for Python Markdown." +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pymdown_extensions-10.0.1-py3-none-any.whl", hash = "sha256:ae66d84013c5d027ce055693e09a4628b67e9dec5bce05727e45b0918e36f274"}, - {file = "pymdown_extensions-10.0.1.tar.gz", hash = "sha256:b44e1093a43b8a975eae17b03c3a77aad4681b3b56fce60ce746dbef1944c8cb"}, + {file = "pymdown_extensions-10.1-py3-none-any.whl", hash = "sha256:ef25dbbae530e8f67575d222b75ff0649b1e841e22c2ae9a20bad9472c2207dc"}, + {file = "pymdown_extensions-10.1.tar.gz", hash = "sha256:508009b211373058debb8247e168de4cbcb91b1bff7b5e961b2c3e864e00b195"}, ] [package.dependencies] @@ -1434,6 +1492,7 @@ pyyaml = "*" name = "pytest" version = "7.4.0" description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1457,6 +1516,7 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-aiohttp" version = "1.0.4" description = "Pytest plugin for aiohttp support" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1476,6 +1536,7 @@ testing = ["coverage (==6.2)", "mypy (==0.931)"] name = "pytest-asyncio" version = "0.21.1" description = "Pytest support for asyncio" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1495,6 +1556,7 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy name = "pytest-cov" version = "2.12.1" description = "Pytest plugin for measuring coverage." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -1514,6 +1576,7 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-textual-snapshot" version = "0.2.0" description = "Snapshot testing for Textual apps" +category = "dev" optional = false python-versions = ">=3.6,<4.0" files = [ @@ -1532,6 +1595,7 @@ textual = ">=0.28.0" name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -1546,6 +1610,7 @@ six = ">=1.5" name = "pytz" version = "2022.7.1" description = "World timezone definitions, modern and historical" +category = "dev" optional = false python-versions = "*" files = [ @@ -1557,6 +1622,7 @@ files = [ name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1606,6 +1672,7 @@ files = [ name = "pyyaml-env-tag" version = "0.1" description = "A custom YAML tag for referencing environment variables in YAML files. " +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1620,6 +1687,7 @@ pyyaml = "*" name = "regex" version = "2023.6.3" description = "Alternative regular expression module, to replace re." +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1717,6 +1785,7 @@ files = [ name = "requests" version = "2.31.0" description = "Python HTTP for Humans." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1738,6 +1807,7 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "rfc3986" version = "1.5.0" description = "Validating URI References per RFC 3986" +category = "dev" optional = false python-versions = "*" files = [ @@ -1755,6 +1825,7 @@ idna2008 = ["idna"] name = "rich" version = "13.4.2" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -1774,6 +1845,7 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] name = "setuptools" version = "68.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1790,6 +1862,7 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1801,6 +1874,7 @@ files = [ name = "smmap" version = "5.0.0" description = "A pure Python implementation of a sliding window memory map manager" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1812,6 +1886,7 @@ files = [ 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" files = [ @@ -1823,6 +1898,7 @@ files = [ name = "syrupy" version = "3.0.6" description = "Pytest Snapshot Test Utility" +category = "dev" optional = false python-versions = ">=3.7,<4" files = [ @@ -1838,6 +1914,7 @@ pytest = ">=5.1.0,<8.0.0" name = "textual-dev" version = "1.0.1" description = "Development tools for working with Textual" +category = "dev" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -1856,6 +1933,7 @@ typing-extensions = ">=4.4.0,<5.0.0" name = "time-machine" version = "2.10.0" description = "Travel through time in your tests." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1922,6 +2000,7 @@ python-dateutil = "*" name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1933,6 +2012,7 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1944,6 +2024,7 @@ files = [ name = "typed-ast" version = "1.5.5" description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1994,6 +2075,7 @@ files = [ name = "types-setuptools" version = "67.8.0.0" description = "Typing stubs for setuptools" +category = "dev" optional = false python-versions = "*" files = [ @@ -2005,6 +2087,7 @@ files = [ name = "typing-extensions" version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2016,6 +2099,7 @@ files = [ name = "tzdata" version = "2022.7" description = "Provider of IANA time zone data" +category = "dev" optional = false python-versions = ">=2" files = [ @@ -2027,6 +2111,7 @@ files = [ name = "uc-micro-py" version = "1.0.2" description = "Micro subset of unicode data files for linkify-it-py projects." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2041,6 +2126,7 @@ test = ["coverage", "pytest", "pytest-cov"] name = "urllib3" version = "2.0.3" description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2056,13 +2142,14 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.23.1" +version = "20.24.0" description = "Virtual Python Environment builder" +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.23.1-py3-none-any.whl", hash = "sha256:34da10f14fea9be20e0fd7f04aba9732f84e593dac291b757ce42e3368a39419"}, - {file = "virtualenv-20.23.1.tar.gz", hash = "sha256:8ff19a38c1021c742148edc4f81cb43d7f8c6816d2ede2ab72af5b84c749ade1"}, + {file = "virtualenv-20.24.0-py3-none-any.whl", hash = "sha256:18d1b37fc75cc2670625702d76849a91ebd383768b4e91382a8d51be3246049e"}, + {file = "virtualenv-20.24.0.tar.gz", hash = "sha256:e2a7cef9da880d693b933db7654367754f14e20650dc60e8ee7385571f8593a3"}, ] [package.dependencies] @@ -2079,6 +2166,7 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess name = "watchdog" version = "3.0.0" description = "Filesystem events monitoring" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2118,6 +2206,7 @@ watchmedo = ["PyYAML (>=3.10)"] name = "yarl" version = "1.9.2" description = "Yet another URL library" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2206,6 +2295,7 @@ typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" optional = false python-versions = ">=3.7" files = [ diff --git a/src/textual/app.py b/src/textual/app.py index fb08198fb8..a9232050e2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -90,10 +90,12 @@ _get_unicode_name_from_key, ) from .messages import CallbackType +from .notifications import Notification, Notifications, SeverityLevel from .reactive import Reactive from .renderables.blank import Blank from .screen import Screen, ScreenResultCallbackType, ScreenResultType from .widget import AwaitMount, Widget +from .widgets._toast import ToastRack if TYPE_CHECKING: from textual_dev.client import DevtoolsClient @@ -443,6 +445,7 @@ def __init__( self._return_value: ReturnType | None = None self._exit = False self._disable_tooltips = False + self._disable_notifications = False self.css_monitor = ( FileMonitor(self.css_path, self._on_css_change) @@ -455,6 +458,7 @@ def __init__( self._batch_count = 0 self.set_class(self.dark, "-dark-mode") self.set_class(not self.dark, "-light-mode") + self._notifications = Notifications() def validate_title(self, title: Any) -> str: """Make sure the title is set to a string.""" @@ -1029,6 +1033,7 @@ async def run_test( headless: bool = True, size: tuple[int, int] | None = (80, 24), tooltips: bool = False, + notifications: bool = False, message_hook: Callable[[Message], None] | None = None, ) -> AsyncGenerator[Pilot, None]: """An asynchronous context manager for testing app. @@ -1048,12 +1053,14 @@ async def run_test( size: Force terminal size to `(WIDTH, HEIGHT)`, or None to auto-detect. tooltips: Enable tooltips when testing. + notifications: Enable notifications when testing. message_hook: An optional callback that will called with every message going through the app. """ from .pilot import Pilot app = self app._disable_tooltips = not tooltips + app._disable_notifications = not notifications app_ready_event = asyncio.Event() def on_app_ready() -> None: @@ -2769,3 +2776,94 @@ def _begin_update(self) -> None: def _end_update(self) -> None: if self._sync_available and self._driver is not None: self._driver.write(SYNC_END) + + def _refresh_notifications(self) -> None: + """Refresh the notifications on the current screen, if one is available.""" + # If we've got a screen to hand... + if self.screen is not None: + try: + # ...see if it has a toast rack. + toast_rack = self.screen.get_child_by_type(ToastRack) + except NoMatches: + # It doesn't. That's fine. Either there won't ever be one, + # or one will turn up. Things will work out later. + return + # Update the toast rack. + toast_rack.show(self._notifications) + + def notify( + self, + message: str, + *, + title: str | None = None, + severity: SeverityLevel = "information", + timeout: float = Notification.timeout, + ) -> Notification: + """Create a notification. + + Args: + message: The message for the notification. + title: The title for the notification. + severity: The severity of the notification. + timeout: The timeout for the notification. + + Returns: + The new notification. + + The `notify` method is used to create an application-wide + notification, shown in a [`Toast`][textual.widgets._toast.Toast], + normally originating in the bottom right corner of the display. + + Notifications can have the following severity levels: + + - `information` + - `warning` + - `error` + + The default is `information`. + + If no `title` is provided, the title of the notification will + reflect the severity. If you wish to create a notification that has + no title whatsoever, pass an empty title (`""`). + + Example: + ```python + # Show an information notification. + self.notify("It's an older code, sir, but it checks out.") + + # Show a warning. Note that Textual's notification system allows + # for the use of Rich console markup. + self.notify( + "Now witness the firepower of this fully " + "[b]ARMED[/b] and [i][b]OPERATIONAL[/b][/i] battle station!", + title="Possible trap detected", + severity="warning", + ) + + # Show an error. Set a longer timeout so it's noticed. + self.notify("It's a trap!", severity="error", timeout=10) + + # Show an information notification, but without any sort of title. + self.notify("It's against my programming to impersonate a deity.", title="") + ``` + """ + notification = Notification(message, title, severity, timeout) + self._notifications.add(notification) + self._refresh_notifications() + return notification + + def unnotify(self, notification: Notification, refresh: bool = True) -> None: + """Remove a notification from the notification collection. + + Args: + notification: The notification to remove. + refresh: Flag to say if the display of notifications should be refreshed. + """ + del self._notifications[notification] + if refresh: + self._refresh_notifications() + + def clear_notifications(self) -> None: + """Clear all the current notifications.""" + self._notifications.clear() + self._refresh_notifications() diff --git a/src/textual/notifications.py b/src/textual/notifications.py new file mode 100644 index 0000000000..cc0cc85de5 --- /dev/null +++ b/src/textual/notifications.py @@ -0,0 +1,108 @@ +"""Provides classes for holding and managing notifications.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from time import time +from typing import Iterator +from uuid import uuid4 + +from rich.repr import Result +from typing_extensions import Literal, Self, TypeAlias + +SeverityLevel: TypeAlias = Literal["information", "warning", "error"] +"""The severity level for a notification.""" + + +@dataclass +class Notification: + """Holds the details of a notification.""" + + message: str + """The message for the notification.""" + + title: str | None = None + """The title for the notification.""" + + severity: SeverityLevel = "information" + """The severity level for the notification.""" + + timeout: float = 3 + """The timeout for the notification.""" + + raised_at: float = field(default_factory=time) + """The time when the notification was raised (in Unix time).""" + + identity: str = field(default_factory=lambda: str(uuid4())) + """The unique identity of the notification.""" + + @property + def time_left(self) -> float: + """The time left until this notification expires""" + return (self.raised_at + self.timeout) - time() + + @property + def has_expired(self) -> bool: + """Has the notification expired?""" + return self.time_left <= 0 + + def __rich_repr__(self) -> Result: + yield "message", self.message + yield "title", self.title, None + yield "severity", self.severity + yield "raised_it", self.raised_at + yield "identity", self.identity + yield "time_left", self.time_left + yield "has_expired", self.has_expired + + +class Notifications: + """Class for managing a collection of notifications.""" + + def __init__(self) -> None: + """Initialise the notification collection.""" + self._notifications: dict[str, Notification] = {} + + def _reap(self) -> Self: + """Remove any expired notifications from the notification collection.""" + for notification in list(self._notifications.values()): + if notification.has_expired: + del self._notifications[notification.identity] + return self + + def add(self, notification: Notification) -> Self: + """Add the given notification to the collection of managed notifications. + + Args: + notification: The notification to add. + + Returns: + Self. + """ + self._reap()._notifications[notification.identity] = notification + return self + + def clear(self) -> Self: + """Clear all the notifications.""" + self._notifications.clear() + return self + + def __len__(self) -> int: + """The number of notifications.""" + return len(self._reap()._notifications) + + def __iter__(self) -> Iterator[Notification]: + return iter(self._reap()._notifications.values()) + + def __contains__(self, notification: Notification) -> bool: + return notification.identity in self._notifications + + def __delitem__(self, notification: Notification) -> None: + try: + del self._reap()._notifications[notification.identity] + except KeyError: + # An attempt to remove a notification we don't know about is a + # no-op. What matters here is that the notification is forgotten + # about, and it looks like a caller has tried to be + # belt-and-braces. We're fine with this. + pass diff --git a/src/textual/screen.py b/src/textual/screen.py index e009e6f229..7a4327cdf7 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -36,12 +36,14 @@ from .css.query import NoMatches, QueryType from .dom import DOMNode from .geometry import Offset, Region, Size +from .notifications import Notification, SeverityLevel from .reactive import Reactive from .renderables.background_screen import BackgroundScreen from .renderables.blank import Blank from .timer import Timer from .widget import Widget from .widgets import Tooltip +from .widgets._toast import ToastRack if TYPE_CHECKING: from typing_extensions import Final @@ -202,10 +204,12 @@ def layers(self) -> tuple[str, ...]: Returns: Tuple of layer names. """ - if self.app._disable_tooltips: - return super().layers - else: - return (*super().layers, "_tooltips") + extras = [] + if not self.app._disable_notifications: + extras.append("_toastrack") + if not self.app._disable_tooltips: + extras.append("_tooltips") + return (*super().layers, *extras) def render(self) -> RenderableType: background = self.styles.background @@ -529,9 +533,18 @@ def scroll_to_center(widget: Widget) -> None: self._update_focus_styles(focused, blurred) def _extend_compose(self, widgets: list[Widget]) -> None: - """Insert the tooltip widget, if required.""" + """Insert Textual's own internal widgets. + + Args: + widgets: The list of widgets to be composed. + + This method adds the tooltip, if required, and also adds the + container for `Toast`s. + """ if not self.app._disable_tooltips: widgets.insert(0, Tooltip(id="textual-tooltip")) + if not self.app._disable_notifications: + widgets.insert(0, ToastRack(id="textual-toastrack")) async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) @@ -727,6 +740,7 @@ def _screen_resized(self, size: Size): def _on_screen_resume(self) -> None: """Screen has resumed.""" self.stack_updates += 1 + self.app._refresh_notifications() size = self.app.size self._refresh_layout(size, full=True) self.refresh() @@ -926,6 +940,30 @@ def action_dismiss( """ self.dismiss(result) + def notify( + self, + message: str, + *, + title: str | None = None, + severity: SeverityLevel = "information", + timeout: float = Notification.timeout, + ) -> Notification: + """Create a notification. + + Args: + message: The message for the notification. + title: The title for the notification. + severity: The severity of the notification. + timeout: The timeout for the notification. + + Returns: + The new notification. + + See [`App.notify`][textual.app.App.notify] for the full + documentation for this method. + """ + return self.app.notify(message, title=title, severity=severity, timeout=timeout) + @rich.repr.auto class ModalScreen(Screen[ScreenResultType]): diff --git a/src/textual/widget.py b/src/textual/widget.py index 35166e369b..ff4c58bc8d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -61,6 +61,7 @@ from .layouts.vertical import VerticalLayout from .message import Message from .messages import CallbackType +from .notifications import Notification, SeverityLevel from .reactive import Reactive from .render import measure from .strip import Strip @@ -3240,3 +3241,27 @@ def action_page_up(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() self.scroll_page_up() + + def notify( + self, + message: str, + *, + title: str | None = None, + severity: SeverityLevel = "information", + timeout: float = Notification.timeout, + ) -> Notification: + """Create a notification. + + Args: + message: The message for the notification. + title: The title for the notification. + severity: The severity of the notification. + timeout: The timeout for the notification. + + Returns: + The new notification. + + See [`App.notify`][textual.app.App.notify] for the full + documentation for this method. + """ + return self.app.notify(message, title=title, severity=severity, timeout=timeout) diff --git a/src/textual/widgets/_toast.py b/src/textual/widgets/_toast.py new file mode 100644 index 0000000000..200598cd21 --- /dev/null +++ b/src/textual/widgets/_toast.py @@ -0,0 +1,186 @@ +"""Widgets for showing notification messages in toasts.""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.text import Text + +from .. import on +from ..containers import Container +from ..css.query import NoMatches +from ..events import Click, Mount +from ..notifications import Notification, Notifications +from ._static import Static + + +class ToastHolder(Container, inherit_css=False): + """Container that holds a single toast. + + Used to control the alignment of each of the toasts in the main toast + container. + """ + + DEFAULT_CSS = """ + ToastHolder { + align-horizontal: right; + width: 1fr; + height: auto; + visibility: hidden; + } + """ + + +class Toast(Static, inherit_css=False): + """A widget for displaying short-lived notifications.""" + + DEFAULT_CSS = """ + Toast { + width: 60; + max-width: 50%; + height: auto; + visibility: visible; + margin-top: 1; + padding: 1 2; + background: $panel; + tint: white 5%; + } + + .toast--title { + text-style: bold; + } + + Toast.-information { + border-left: tall $success; + } + + Toast.-information .toast--title { + color: $success-darken-1; + } + + Toast.-warning { + border-left: tall $warning; + } + + Toast.-warning .toast--title { + color: $warning-darken-1; + } + + Toast.-error { + border-left: tall $error; + } + + Toast.-error .toast--title { + color: $error-darken-1; + } + + Toast.-empty-title { + + } + """ + + COMPONENT_CLASSES = {"toast--title"} + + def __init__(self, notification: Notification) -> None: + """Initialise the toast. + + Args: + notification: The notification to show in the toast. + """ + super().__init__( + classes=f"-{notification.severity} {'-empty-title' if not notification.title else ''}" + ) + self._notification = notification + self._timeout = notification.time_left + + def render(self) -> RenderableType: + notification = self._notification + if notification.title: + header_style = self.get_component_rich_style("toast--title") + notification_text = Text.assemble( + (notification.title, header_style), + "\n", + Text.from_markup(notification.message), + ) + else: + notification_text = Text.assemble( + Text.from_markup(notification.message), + ) + return notification_text + + def _on_mount(self, _: Mount) -> None: + """Set the time running once the toast is mounted.""" + self.set_timer(self._timeout, self._expire) + + @on(Click) + def _expire(self) -> None: + """Remove the toast once the timer has expired.""" + # Before we removed ourself, we also call on the app to forget about + # the notification that caused us to exist. Note that we tell the + # app to not bother refreshing the display on our account, we're + # about to handle that anyway. + self.app.unnotify(self._notification, refresh=False) + # Note that we attempt to remove our parent, because we're wrapped + # inside an alignment container. The testing that we are is as much + # to keep type checkers happy as anything else. + (self.parent if isinstance(self.parent, ToastHolder) else self).remove() + + +class ToastRack(Container, inherit_css=False): + """A container for holding toasts.""" + + DEFAULT_CSS = """ + ToastRack { + layer: _toastrack; + width: 1fr; + height: auto; + dock: top; + align: right bottom; + visibility: hidden; + layout: vertical; + overflow-y: scroll; + margin-bottom: 1; + } + """ + + @staticmethod + def _toast_id(notification: Notification) -> str: + """Create a Textual-DOM-internal ID for the given notification. + + Args: + notification: The notification to create the ID for. + + Returns: + An ID for the notification that can be used within the DOM. + """ + return f"--textual-toast-{notification.identity}" + + def show(self, notifications: Notifications) -> None: + """Show the notifications as toasts. + + Args: + notifications: The notifications to show. + """ + + # Look for any stale toasts and remove them. + for toast in self.query(Toast): + if toast._notification not in notifications: + toast.remove() + + # Gather up all the notifications that we don't have toasts for yet. + new_toasts: list[Notification] = [] + for notification in notifications: + try: + # See if there's already a toast for that notification. + _ = self.get_child_by_id(self._toast_id(notification)) + except NoMatches: + if not notification.has_expired: + new_toasts.append(notification) + + # If we got any... + if new_toasts: + # ...mount them. + self.mount_all( + ToastHolder(Toast(toast), id=self._toast_id(toast)) + for toast in new_toasts + ) + self.call_later(self.scroll_end, animate=False, force=True) diff --git a/tests/notifications/test_all_levels_notifications.py b/tests/notifications/test_all_levels_notifications.py new file mode 100644 index 0000000000..2b9c4be32b --- /dev/null +++ b/tests/notifications/test_all_levels_notifications.py @@ -0,0 +1,28 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widget import Widget + + +class NotifyWidget(Widget): + def on_mount(self) -> None: + self.notify("test", timeout=60) + + +class NotifyScreen(Screen): + def on_mount(self) -> None: + self.notify("test", timeout=60) + + def compose(self) -> ComposeResult: + yield NotifyWidget() + + +class NotifyApp(App[None]): + def on_mount(self) -> None: + self.notify("test", timeout=60) + self.push_screen(NotifyScreen()) + + +async def test_all_levels_of_notification() -> None: + """All levels within the DOM should be able to notify.""" + async with NotifyApp().run_test() as pilot: + assert len(pilot.app._notifications) == 3 diff --git a/tests/notifications/test_app_notifications.py b/tests/notifications/test_app_notifications.py new file mode 100644 index 0000000000..01f54ae605 --- /dev/null +++ b/tests/notifications/test_app_notifications.py @@ -0,0 +1,49 @@ +from time import sleep + +from textual.app import App + + +class NotificationApp(App[None]): + pass + + +async def test_app_no_notifications() -> None: + """An app with no notifications should have an empty notification list.""" + async with NotificationApp().run_test() as pilot: + assert len(pilot.app._notifications) == 0 + + +async def test_app_with_notifications() -> None: + """An app with notifications should have notifications in the list.""" + async with NotificationApp().run_test() as pilot: + pilot.app.notify("test") + assert len(pilot.app._notifications) == 1 + + +async def test_app_with_removing_notifications() -> None: + """An app with notifications should have notifications in the list, which can be removed.""" + async with NotificationApp().run_test() as pilot: + notification = pilot.app.notify("test") + assert len(pilot.app._notifications) == 1 + pilot.app.unnotify(notification) + assert len(pilot.app._notifications) == 0 + + +async def test_app_with_notifications_that_expire() -> None: + """Notifications should expire from an app.""" + async with NotificationApp().run_test() as pilot: + for n in range(100): + pilot.app.notify("test", timeout=(0.5 if bool(n % 2) else 60)) + assert len(pilot.app._notifications) == 100 + sleep(0.6) + assert len(pilot.app._notifications) == 50 + + +async def test_app_clearing_notifications() -> None: + """The application should be able to clear all notifications.""" + async with NotificationApp().run_test() as pilot: + for _ in range(100): + pilot.app.notify("test", timeout=120) + assert len(pilot.app._notifications) == 100 + pilot.app.clear_notifications() + assert len(pilot.app._notifications) == 0 diff --git a/tests/notifications/test_notification.py b/tests/notifications/test_notification.py new file mode 100644 index 0000000000..5793a88345 --- /dev/null +++ b/tests/notifications/test_notification.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from time import sleep + +from textual.notifications import Notification + + +def test_message() -> None: + """A notification should not change the message.""" + assert Notification("test").message == "test" + + +def test_default_title() -> None: + """A notification with no title should have a None title.""" + assert Notification("test").title is None + + +def test_default_severity_level() -> None: + """The default severity level should be as expected.""" + assert Notification("test").severity == "information" + + +def test_default_timeout() -> None: + """The default timeout should be as expected.""" + assert Notification("test").timeout == 3 + + +def test_identity_is_unique() -> None: + """A collection of notifications should, by default, have unique IDs.""" + notifications: set[str] = set() + for _ in range(1000): + notifications.add(Notification("test").identity) + assert len(notifications) == 1000 + + +def test_time_out() -> None: + test = Notification("test", timeout=0.5) + assert test.has_expired is False + sleep(0.6) + assert test.has_expired is True diff --git a/tests/notifications/test_notifications.py b/tests/notifications/test_notifications.py new file mode 100644 index 0000000000..b8760627ee --- /dev/null +++ b/tests/notifications/test_notifications.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from time import sleep + +from textual.notifications import Notification, Notifications + + +def test_empty_to_start_with() -> None: + """We should have no notifications if we've not raised any.""" + assert len(Notifications()) == 0 + + +def test_many_notifications() -> None: + """Adding lots of long-timeout notifications should result in them being in the list.""" + tester = Notifications() + for _ in range(100): + tester.add(Notification("test", timeout=60)) + assert len(tester) == 100 + + +def test_timeout() -> None: + """Notifications should timeout from the list.""" + tester = Notifications() + for n in range(100): + tester.add(Notification("test", timeout=(0.5 if bool(n % 2) else 60))) + assert len(tester) == 100 + sleep(0.6) + assert len(tester) == 50 + + +def test_in() -> None: + """It should be possible to test if a notification is in a collection.""" + tester = Notifications() + within = Notification("within", timeout=120) + outwith = Notification("outwith", timeout=120) + tester.add(within) + assert within in tester + assert outwith not in tester + + +def test_remove_notification() -> None: + """It should be possible to remove a notification.""" + tester = Notifications() + first = Notification("first", timeout=120) + second = Notification("second", timeout=120) + third = Notification("third", timeout=120) + tester.add(first) + tester.add(second) + tester.add(third) + assert list(tester) == [first, second, third] + del tester[second] + assert list(tester) == [first, third] + del tester[first] + assert list(tester) == [third] + del tester[third] + assert list(tester) == [] + + +def test_remove_notification_multiple_times() -> None: + """It should be possible to remove the same notification more than once without an error.""" + tester = Notifications() + alert = Notification("delete me") + tester.add(alert) + assert list(tester) == [alert] + del tester[alert] + assert list(tester) == [] + del tester[alert] + assert list(tester) == [] + + +def test_clear() -> None: + """It should be possible to clear all notifications.""" + tester = Notifications() + for _ in range(100): + tester.add(Notification("test", timeout=120)) + assert len(tester) == 100 + tester.clear() + assert len(tester) == 0 diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 7a66a0da90..99113705b5 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -18847,6 +18847,480 @@ ''' # --- +# name: test_notifications_example + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ToastApp + + + + + + + + + + + + + + It's an older code, sir, but it  + checks out. + + + + Possible trap detected + Now witness the firepower of  + this fully ARMED and OPERATIONAL + battle station! + + + + It's a trap! + + + + It's against my programming to  + impersonate a deity. + + + + + + + ''' +# --- +# name: test_notifications_through_modes + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NotifyThroughModesApp + + + + + + + + + + This is a mode screen + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + + + + + + + ''' +# --- +# name: test_notifications_through_screens + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NotifyDownScreensApp + + + + + + + + + + Screen 10 + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + + + + + + + ''' +# --- # name: test_offsets ''' diff --git a/tests/snapshot_tests/snapshot_apps/notification_through_modes.py b/tests/snapshot_tests/snapshot_apps/notification_through_modes.py new file mode 100644 index 0000000000..5c0e0ee3e8 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/notification_through_modes.py @@ -0,0 +1,26 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Label + +class Mode(Screen): + + def compose(self) -> ComposeResult: + yield Label("This is a mode screen") + + +class NotifyThroughModesApp(App[None]): + + MODES = { + "test": Mode() + } + + def compose(self) -> ComposeResult: + yield Label("Base screen") + + def on_mount(self): + for n in range(10): + self.notify(str(n)) + self.switch_mode("test") + +if __name__ == "__main__": + NotifyThroughModesApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/notification_through_screens.py b/tests/snapshot_tests/snapshot_apps/notification_through_screens.py new file mode 100644 index 0000000000..34880b0e74 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/notification_through_screens.py @@ -0,0 +1,31 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Label + +class StackableScreen(Screen): + + TARGET_DEPTH = 10 + + def __init__(self, count:int = TARGET_DEPTH) -> None: + super().__init__() + self._number = count + + def compose(self) -> ComposeResult: + yield Label(f"Screen {self.TARGET_DEPTH - self._number}") + + def on_mount(self) -> None: + if self._number > 0: + self.app.push_screen(StackableScreen(self._number - 1)) + +class NotifyDownScreensApp(App[None]): + + def compose(self) -> ComposeResult: + yield Label("Base screen") + + def on_mount(self): + for n in range(10): + self.notify(str(n)) + self.push_screen(StackableScreen()) + +if __name__ == "__main__": + NotifyDownScreensApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index bdeb2e1763..08553015cd 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -575,3 +575,13 @@ def test_textual_dev_easing_preview(snap_compare): def test_textual_dev_keys_preview(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "dev_previews_keys.py", press=["a", "b"]) + + +def test_notifications_example(snap_compare) -> None: + assert snap_compare(WIDGET_EXAMPLES_DIR / "toast.py") + +def test_notifications_through_screens(snap_compare) -> None: + assert snap_compare(SNAPSHOT_APPS_DIR / "notification_through_screens.py") + +def test_notifications_through_modes(snap_compare) -> None: + assert snap_compare(SNAPSHOT_APPS_DIR / "notification_through_modes.py")