203 lines
5.8 KiB
Python
203 lines
5.8 KiB
Python
"""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)
|