"""Widgets for showing notification messages in toasts.""" from __future__ import annotations from typing import ClassVar from textual import on from textual.containers import Container from textual.content import Content from textual.css.query import NoMatches from textual.events import Click, Mount from textual.notifications import Notification, Notifications from textual.widgets._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; margin-top: 1; visibility: visible; padding: 1 1; background: $panel-lighten-1; link-background: initial; link-color: $foreground; link-style: underline; link-background-hover: $primary; link-color-hover: $foreground; link-style-hover: bold not underline; } .toast--title { text-style: bold; color: $foreground; } Toast.-information { border-left: outer $success; } Toast.-information .toast--title { color: $text-success; } Toast.-warning { border-left: outer $warning; } Toast.-warning .toast--title { color: $text-warning; } Toast.-error { border-left: outer $error; } Toast.-error .toast--title { color: $text-error; } """ COMPONENT_CLASSES: ClassVar[set[str]] = {"toast--title"} """ | Class | Description | | :- | :- | | `toast--title` | Targets the title of the toast. | """ DEFAULT_CLASSES = "-textual-system" def __init__(self, notification: Notification) -> None: """Initialise the toast. Args: notification: The notification to show in the toast. """ super().__init__(classes=f"-{notification.severity}") self._notification = notification self._timeout = notification.time_left def render(self) -> Content: """Render the toast's content. Returns: A Rich renderable for the title and content of the Toast. """ notification = self._notification message_content = ( Content.from_markup(notification.message) if notification.markup else Content(notification.message) ) if notification.title: header_style = self.get_visual_style("toast--title") message_content = Content.assemble( (notification.title, header_style), "\n", message_content ) return message_content 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 { display: none; layer: _toastrack; width: 1fr; height: auto; dock: bottom; align: right bottom; visibility: hidden; layout: vertical; overflow-y: scroll; margin-bottom: 1; } """ DEFAULT_CLASSES = "-textual-system" @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. """ self.display = bool(notifications) # 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)