chiron 0.1.0

Graphical and command-line interface tools for Biomechanics Foundation
Documentation
use super::windows::Window;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy_egui::EguiContext;
use std::ops::{Deref, DerefMut};
use std::time::{Duration, Instant};

const TOAST_DURATION: Duration = Duration::from_secs(5);
const TOAST_WIDTH: f32 = 300.;
const TOAST_HEIGHT: f32 = 70.;
const TOAST_INNER_PADDING: f32 = 8.;
const TOAST_OUTER_PADDING: f32 = 6.;
const TOAST_ICON_SIZE: f32 = 32.;

const OVERLAY_ICON_SIZE: f32 = 64.;

pub struct NotificationsPlugin;

impl Plugin for NotificationsPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<Toast>()
            .init_resource::<Notifications>()
            .add_systems(Update, (collect_toast_events, show_notifications_system));
    }
}

pub fn collect_toast_events(
    mut events: EventReader<Toast>,
    mut notifications: ResMut<Notifications>,
) {
    for event in events.read() {
        notifications.add(event.clone());
    }
}

#[derive(Event, Clone, Debug)]
pub struct Toast {
    id: uuid::Uuid,
    pub title: String,
    pub icon: Icon,
    pub color: egui::Color32,
    created_at: Option<Instant>,
}

impl Toast {
    pub fn success(title: &str) -> Self {
        Self {
            id: uuid::Uuid::new_v4(),
            title: title.to_owned(),
            icon: Icon::Success,
            color: egui::Color32::from_rgb(82, 196, 26),
            created_at: Some(Instant::now()),
        }
    }

    pub fn warning(title: &str) -> Self {
        Self {
            id: uuid::Uuid::new_v4(),
            title: title.to_owned(),
            icon: Icon::Warning,
            color: egui::Color32::from_rgb(250, 173, 20),
            created_at: Some(Instant::now()),
        }
    }

    pub fn error(title: &str) -> Self {
        Self {
            id: uuid::Uuid::new_v4(),
            title: title.to_owned(),
            icon: Icon::Error,
            color: egui::Color32::from_rgb(255, 77, 79),
            created_at: Some(Instant::now()),
        }
    }

    pub fn info(title: &str) -> Self {
        Self {
            id: uuid::Uuid::new_v4(),
            title: title.to_owned(),
            icon: Icon::Info,
            color: egui::Color32::from_rgb(22, 119, 255),
            created_at: Some(Instant::now()),
        }
    }

    pub fn show(&self, ui: &mut egui::Ui) {
        egui::Frame::none()
            .fill(self.color)
            .rounding(5.0)
            .inner_margin(TOAST_INNER_PADDING)
            .outer_margin(TOAST_OUTER_PADDING)
            .show(ui, |ui| {
                ui.set_width(TOAST_WIDTH - TOAST_OUTER_PADDING * 3.);
                ui.horizontal(|ui| {
                    ui.label(
                        egui::RichText::new(self.icon.to_emoji())
                            .color(egui::Color32::BLACK)
                            .size(TOAST_ICON_SIZE),
                    );
                    ui.label(
                        egui::RichText::new(&self.title)
                            .color(egui::Color32::BLACK)
                            .strong()
                            .size(14.0),
                    );
                });
            });
    }
}

#[derive(Clone, Debug)]
pub struct Overlay {
    pub icon: Icon,
    pub window_outline: bool,
}

impl Overlay {
    pub fn new(icon: Icon, window_outline: bool) -> Self {
        Self {
            icon,
            window_outline,
        }
    }
}

#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash)]
pub enum Icon {
    Info,
    Success,
    Warning,
    Error,
    Upload,
}

impl Icon {
    pub fn to_emoji(&self) -> &str {
        match self {
            Icon::Info => "🚩",
            Icon::Success => "",
            Icon::Warning => "⚠️",
            Icon::Error => "",
            Icon::Upload => "📤",
        }
    }
}

#[derive(Resource, Default)]
pub struct Notifications {
    pub queue: NotificationQueue,
}

impl Notifications {
    pub fn add(&mut self, toast: Toast) {
        self.queue.push(toast);
    }

    pub fn pop_by_id(&mut self, id: uuid::Uuid) {
        self.queue.pop_by_id(id);
    }

    pub fn overlay(&mut self, icon: Icon, window_outline: bool) {
        self.queue.overlay(icon, window_outline);
    }

    pub fn remove_overlay(&mut self) {
        self.queue.remove_overlay();
    }
}

#[derive(Default)]
pub struct NotificationQueue {
    toasts: Vec<Toast>,
    overlay: Option<Overlay>,
}

impl Deref for NotificationQueue {
    type Target = Vec<Toast>;

    fn deref(&self) -> &Self::Target {
        &self.toasts
    }
}

impl DerefMut for NotificationQueue {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.toasts
    }
}

impl NotificationQueue {
    pub fn pop_by_id(&mut self, id: uuid::Uuid) {
        self.toasts.retain(|notification| notification.id != id);
    }

    pub fn overlay(&mut self, icon: Icon, window_outline: bool) {
        self.overlay = Some(Overlay::new(icon, window_outline));
    }

    pub fn remove_overlay(&mut self) {
        self.overlay = None;
    }

    fn show(&mut self, ui: &mut egui::Ui, center: egui::Pos2) {
        let mut ids_to_remove = Vec::new();
        let mut child_ui = ui.child_ui(
            egui::Rect::from_min_size(
                egui::Pos2::new(
                    ui.available_width() - TOAST_WIDTH - TOAST_OUTER_PADDING * 2.,
                    TOAST_OUTER_PADDING,
                ),
                egui::Vec2::new(TOAST_WIDTH, TOAST_HEIGHT),
            ),
            egui::Layout::default(),
        );
        for toast in self.toasts.iter() {
            if let Some(created_at) = toast.created_at {
                if created_at.elapsed() > TOAST_DURATION {
                    ids_to_remove.push(toast.id.clone());
                }
            }
            toast.show(&mut child_ui);
        }
        for id in ids_to_remove {
            self.pop_by_id(id);
        }
        if let Some(overlay) = &self.overlay {
            if overlay.window_outline {
                ui.painter().rect_filled(
                    egui::Rect::from_center_size(
                        center,
                        egui::Vec2::new(OVERLAY_ICON_SIZE, OVERLAY_ICON_SIZE),
                    ),
                    5.0,
                    egui::Color32::from_rgba_unmultiplied(255, 255, 255, 50),
                );
                ui.with_layout(
                    egui::Layout::centered_and_justified(egui::Direction::TopDown),
                    |ui| {
                        ui.label(
                            egui::RichText::new(overlay.icon.to_emoji())
                                .color(egui::Color32::WHITE)
                                .size(OVERLAY_ICON_SIZE * 0.8),
                        );
                    },
                );
            }
        }
    }
}

impl Window for Notifications {
    fn ui(&mut self, _world: &mut World, ctx: &mut egui::Context) {
        let mut stroke = egui::Stroke::new(5.0, egui::Color32::TRANSPARENT);
        let center = ctx.screen_rect().center();
        if let Some(overlay) = &self.queue.overlay {
            if overlay.window_outline {
                stroke.color = egui::Color32::WHITE;
            }
        }
        egui::Window::new(self.title())
            .frame(egui::Frame {
                fill: egui::Color32::TRANSPARENT,
                stroke,
                ..Default::default()
            })
            .title_bar(false)
            .resizable(false)
            .collapsible(false)
            .interactable(false)
            .fixed_rect(ctx.screen_rect())
            .show(ctx, |ui| {
                self.queue.show(ui, center);
                ui.allocate_space(ui.available_size() - egui::Vec2::new(0., OVERLAY_ICON_SIZE))
            });
    }

    fn title(&mut self) -> egui::WidgetText {
        "Notifications".into()
    }
}

pub fn show_notifications_system(world: &mut World) {
    let Ok(egui_context) = world
        .query_filtered::<&mut EguiContext, With<PrimaryWindow>>()
        .get_single(world)
    else {
        return;
    };
    let mut egui_context = egui_context.clone();

    world.resource_scope::<Notifications, _>(|world, mut notifications| {
        notifications.ui(world, egui_context.get_mut())
    });
}