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<_>>(),
})
}
}