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
+ '''
+
+
+ '''
+# ---
+# name: test_notifications_through_modes
+ '''
+
+
+ '''
+# ---
+# name: test_notifications_through_screens
+ '''
+
+
+ '''
+# ---
# name: test_offsets
'''