use egui::{Align2, Color32, Context, Order, Pos2, RichText, Ui, Vec2};
use super::toast::Toast;
use super::toasts::{Toasts, current_time_seconds};
use crate::icons::icons;
use crate::tokens::DESIGN_TOKENS;
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
pub enum NotificationPosition {
TopRight,
#[default]
BottomRight,
BottomLeft,
TopLeft,
}
impl NotificationPosition {
fn anchor(&self) -> Align2 {
match self {
NotificationPosition::TopRight => Align2::RIGHT_TOP,
NotificationPosition::BottomRight => Align2::RIGHT_BOTTOM,
NotificationPosition::BottomLeft => Align2::LEFT_BOTTOM,
NotificationPosition::TopLeft => Align2::LEFT_TOP,
}
}
fn stack_direction(&self) -> f32 {
match self {
NotificationPosition::TopRight | NotificationPosition::TopLeft => 1.0,
NotificationPosition::BottomRight | NotificationPosition::BottomLeft => -1.0,
}
}
fn base_pos(&self, screen_rect: egui::Rect) -> Pos2 {
let margin = DESIGN_TOKENS.spacing.lg;
match self {
NotificationPosition::TopRight => {
Pos2::new(screen_rect.right() - margin, screen_rect.top() + margin)
}
NotificationPosition::BottomRight => {
Pos2::new(screen_rect.right() - margin, screen_rect.bottom() - margin)
}
NotificationPosition::BottomLeft => {
Pos2::new(screen_rect.left() + margin, screen_rect.bottom() - margin)
}
NotificationPosition::TopLeft => {
Pos2::new(screen_rect.left() + margin, screen_rect.top() + margin)
}
}
}
}
pub struct NotificationPanelConfig {
pub position: NotificationPosition,
pub max_visible: usize,
pub width: f32,
pub gap: f32,
}
impl Default for NotificationPanelConfig {
fn default() -> Self {
Self {
position: NotificationPosition::BottomRight,
max_visible: 5,
width: DESIGN_TOKENS.sizing.notification.panel_width,
gap: DESIGN_TOKENS.spacing.md,
}
}
}
pub struct NotificationPanel {
config: NotificationPanelConfig,
}
impl Default for NotificationPanel {
fn default() -> Self {
Self::new()
}
}
impl NotificationPanel {
pub fn new() -> Self {
Self {
config: NotificationPanelConfig::default(),
}
}
pub fn position(mut self, position: NotificationPosition) -> Self {
self.config.position = position;
self
}
pub fn max_visible(mut self, max: usize) -> Self {
self.config.max_visible = max;
self
}
pub fn width(mut self, width: f32) -> Self {
self.config.width = width;
self
}
pub fn show_with_toasts(&self, ctx: &Context, toasts: &mut Toasts) {
toasts.cleanup_expired();
if toasts.is_empty() {
return;
}
let dismissed = self.show_toasts(ctx, toasts.toasts());
for id in dismissed {
toasts.remove(id);
}
ctx.request_repaint();
}
fn show_toasts(&self, ctx: &Context, toasts: &[Toast]) -> Vec<u64> {
let mut dismissed = Vec::new();
let screen_rect = ctx.content_rect();
let base_pos = self.config.position.base_pos(screen_rect);
let stack_dir = self.config.position.stack_direction();
let visible_toasts: Vec<_> = toasts.iter().rev().take(self.config.max_visible).collect();
let mut y_offset = 0.0;
for toast in visible_toasts {
let toast_id = egui::Id::new("toast").with(toast.id);
let pos = match self.config.position {
NotificationPosition::TopRight | NotificationPosition::TopLeft => {
Pos2::new(base_pos.x, base_pos.y + y_offset)
}
NotificationPosition::BottomRight | NotificationPosition::BottomLeft => {
Pos2::new(base_pos.x, base_pos.y - y_offset)
}
};
let response = egui::Area::new(toast_id)
.order(Order::Foreground)
.anchor(self.config.position.anchor(), [0.0, 0.0])
.fixed_pos(pos)
.show(ctx, |ui| self.render_toast(ui, toast));
y_offset += (response.response.rect.height() + self.config.gap) * stack_dir.abs();
if response.inner {
dismissed.push(toast.id);
}
}
dismissed
}
fn render_toast(&self, ui: &mut Ui, toast: &Toast) -> bool {
let mut dismissed = false;
let bg_color = toast.kind.bg_color();
let text_color = toast.kind.text_color();
let frame = egui::Frame::new()
.fill(bg_color)
.corner_radius(DESIGN_TOKENS.rounding.md)
.inner_margin(egui::Margin::same(DESIGN_TOKENS.spacing.lg as i8))
.shadow(egui::epaint::Shadow {
offset: [0, 2],
blur: 8,
spread: 0,
color: Color32::from_black_alpha(60),
});
frame.show(ui, |ui| {
ui.set_width(self.config.width);
ui.horizontal(|ui| {
let icon = toast.kind.icon();
let icon_size = DESIGN_TOKENS.sizing.icon_md;
ui.add(icon.as_image_tinted(Vec2::splat(icon_size), text_color));
ui.add_space(DESIGN_TOKENS.spacing.md);
ui.vertical(|ui| {
if let Some(title) = &toast.title {
ui.label(RichText::new(title).color(text_color).strong());
}
ui.label(RichText::new(&toast.message).color(text_color));
});
if toast.dismissible {
ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
let close_btn = ui.add(icons::CLOSE.as_image_tinted(
Vec2::splat(DESIGN_TOKENS.sizing.icon_sm),
text_color.linear_multiply(0.7),
));
if close_btn.clicked() {
dismissed = true;
}
});
}
});
if toast.duration > 0.0 {
ui.add_space(DESIGN_TOKENS.spacing.sm);
let current_time = current_time_seconds();
let fraction = toast.remaining_fraction(current_time);
let (rect, _) = ui.allocate_exact_size(
Vec2::new(
self.config.width - DESIGN_TOKENS.spacing.xxl,
DESIGN_TOKENS.spacing.xs,
),
egui::Sense::hover(),
);
ui.painter()
.rect_filled(rect, 1.0, text_color.linear_multiply(0.2));
let progress_rect = egui::Rect::from_min_size(
rect.min,
Vec2::new(rect.width() * fraction, rect.height()),
);
ui.painter()
.rect_filled(progress_rect, 1.0, text_color.linear_multiply(0.5));
}
});
dismissed
}
}