Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-app/src/toast.rs

//! **`Toasts` — a notification queue.** Generalises the deck's single
//! component-clone toast into a stacked queue of levelled notifications with
//! per-toast time-to-live. Expiry is driven by `tick(dt)` (injected in tests for
//! determinism, FC-7; the ambient frame `dt` in live use), so state stays in the
//! struct (FC-1) and is observable (`state_json`).

use serde_json::{Value, json};

/// Notification severity.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
    Info,
    Success,
    Warn,
    Error,
}

impl Level {
    pub fn label(self) -> &'static str {
        match self {
            Level::Info => "info",
            Level::Success => "success",
            Level::Warn => "warn",
            Level::Error => "error",
        }
    }
    fn color(self) -> egui::Color32 {
        match self {
            Level::Info => egui::Color32::from_rgb(70, 130, 220),
            Level::Success => egui::Color32::from_rgb(90, 200, 140),
            Level::Warn => egui::Color32::from_rgb(230, 170, 60),
            Level::Error => facett_core::look::RED,
        }
    }
}

struct Toast {
    id: u64,
    level: Level,
    text: String,
    /// Seconds left; `f64::INFINITY` = sticky (manual dismiss only).
    remaining: f64,
}

/// Default time-to-live for a pushed toast (seconds).
pub const DEFAULT_TTL: f64 = 4.0;

/// A stacked queue of notifications.
#[derive(Default)]
pub struct Toasts {
    items: Vec<Toast>,
    next_id: u64,
    last_time: Option<f64>,
}

impl Toasts {
    pub fn new() -> Self {
        Self::default()
    }

    /// Push a toast that auto-dismisses after [`DEFAULT_TTL`]. Returns its id.
    pub fn push(&mut self, level: Level, text: impl Into<String>) -> u64 {
        self.push_ttl(level, text, DEFAULT_TTL)
    }
    /// Push a sticky toast (dismissed only by the user / [`dismiss`](Self::dismiss)).
    pub fn push_sticky(&mut self, level: Level, text: impl Into<String>) -> u64 {
        self.push_ttl(level, text, f64::INFINITY)
    }
    pub fn push_ttl(&mut self, level: Level, text: impl Into<String>, ttl: f64) -> u64 {
        let id = self.next_id;
        self.next_id += 1;
        self.items.push(Toast { id, level, text: text.into(), remaining: ttl });
        id
    }

    pub fn dismiss(&mut self, id: u64) -> bool {
        let before = self.items.len();
        self.items.retain(|t| t.id != id);
        self.items.len() != before
    }
    pub fn clear(&mut self) {
        self.items.clear();
    }
    pub fn len(&self) -> usize {
        self.items.len()
    }
    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    /// Advance expiry by `dt` seconds and drop expired toasts. Deterministic —
    /// drive it from an injected clock in tests, the ambient `dt` in [`show`](Self::show).
    pub fn tick(&mut self, dt: f64) {
        for t in &mut self.items {
            if t.remaining.is_finite() {
                t.remaining -= dt;
            }
        }
        self.items.retain(|t| t.remaining > 0.0);
    }

    /// Draw the toasts, stacked top-right, ticking expiry from the ambient frame
    /// `dt`. A toast's ✕ dismisses it.
    pub fn show(&mut self, ui: &mut egui::Ui) {
        // Ambient expiry (live path; tests drive tick() directly).
        let now = ui.input(|i| i.time);
        let dt = self.last_time.map(|t| (now - t).max(0.0)).unwrap_or(0.0);
        self.last_time = Some(now);
        self.tick(dt);
        if self.items.is_empty() {
            return;
        }
        ui.ctx().request_repaint(); // keep counting down

        let mut dismiss: Option<u64> = None;
        let snapshot: Vec<(u64, Level, String)> = self.items.iter().map(|t| (t.id, t.level, t.text.clone())).collect();
        egui::Area::new(ui.id().with("toasts"))
            .order(egui::Order::Foreground)
            .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-12.0, 12.0))
            .show(ui.ctx(), |ui| {
                ui.set_max_width(300.0);
                for (id, level, text) in &snapshot {
                    egui::Frame::popup(ui.style())
                        .fill(level.color().gamma_multiply(0.18))
                        .stroke(egui::Stroke::new(1.0_f32, level.color()))
                        .inner_margin(8.0)
                        .show(ui, |ui| {
                            ui.horizontal(|ui| {
                                ui.colored_label(level.color(), level.label().to_uppercase());
                                ui.label(text);
                                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                                    if ui.small_button("✕").clicked() {
                                        dismiss = Some(*id);
                                    }
                                });
                            });
                        });
                    ui.add_space(6.0);
                }
            });
        if let Some(id) = dismiss {
            self.dismiss(id);
        }
    }

    pub fn state_json(&self) -> Value {
        json!({
            "count": self.items.len(),
            "items": self.items.iter().map(|t| json!({
                "id": t.id,
                "level": t.level.label(),
                "text": t.text,
                "sticky": !t.remaining.is_finite(),
            })).collect::<Vec<_>>(),
        })
    }
}