facett-toast/src/lib.rs
//! **facett-toast** — a toast/snackbar overlay for ordning.
//!
//! Timed notifications (assignment, @mention, SLA breach) stacked in a corner and
//! auto-dismissed. The widget only *shows* what it's given — the **digest /
//! quiet-hours policy** ([manual ch.09](../../manual/09-collaboration.md)) is the
//! host's job; a toast that reaches here is one the host decided is immediate.
//!
//! Time is **host-driven** (`tick(dt)`) so the widget is deterministic/testable —
//! it never reads the clock.
//!
//! Design: `ordning/.nornir/widgets/facett-toast.md`. Built in ordning;
//! **destined for facett**.
use egui::{Align2, Color32, Context};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Level {
Info,
Success,
Warn,
Error,
}
impl Level {
fn color(self) -> Color32 {
match self {
Level::Info => Color32::from_rgb(90, 150, 210),
Level::Success => Color32::from_rgb(90, 180, 120),
Level::Warn => Color32::from_rgb(220, 160, 60),
Level::Error => Color32::from_rgb(220, 80, 80),
}
}
fn icon(self) -> &'static str {
match self {
Level::Info => "ℹ",
Level::Success => "✓",
Level::Warn => "⚠",
Level::Error => "✕",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct Toast {
id: u64,
text: String,
level: Level,
remaining: f32, // seconds; counts down via tick()
/// Optional case key the toast links to (clicking it navigates).
case: Option<String>,
}
#[derive(Default)]
pub struct Toasts {
items: Vec<Toast>,
next_id: u64,
/// Set when the user clicks a toast that has a case link.
clicked_case: Option<String>,
}
impl Toasts {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, text: impl Into<String>, level: Level, ttl_secs: f32) -> u64 {
self.push_case(text, level, ttl_secs, None)
}
pub fn push_case(
&mut self,
text: impl Into<String>,
level: Level,
ttl_secs: f32,
case: Option<String>,
) -> u64 {
let id = self.next_id;
self.next_id += 1;
self.items.push(Toast { id, text: text.into(), level, remaining: ttl_secs.max(0.5), case });
id
}
pub fn dismiss(&mut self, id: u64) {
self.items.retain(|t| t.id != id);
}
pub fn clear(&mut self) {
self.items.clear();
}
/// Advance time by `dt` seconds; drop expired toasts. Host calls this each
/// frame with the real delta (the widget never reads the clock itself).
pub fn tick(&mut self, dt: f32) {
for t in &mut self.items {
t.remaining -= dt;
}
self.items.retain(|t| t.remaining > 0.0);
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// The case key of a toast clicked since the last call, if any.
pub fn take_clicked_case(&mut self) -> Option<String> {
self.clicked_case.take()
}
/// The **shared demo fixture** — one toast of every [`Level`], one of them
/// case-linked (so the clickable-link branch paints). Consumed by the robot
/// and matrix tests (L5: one fixture, not one per test file).
pub fn local() -> Self {
let mut t = Self::new();
t.push("Sparat", Level::Success, 4.0);
t.push("Kön är lång", Level::Warn, 6.0);
t.push_case("Beslut misslyckades", Level::Error, 8.0, Some("BL-12".into()));
t.push("3 nya ärenden", Level::Info, 5.0);
t
}
pub fn title(&self) -> &str {
"Notifications"
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"count": self.items.len(),
"toasts": self.items.iter().map(|t| serde_json::json!({
"id": t.id, "text": t.text, "level": format!("{:?}", t.level),
"remaining": t.remaining, "case": t.case,
})).collect::<Vec<_>>(),
})
}
/// Paint the stack as a top-right overlay. Call once per frame with the
/// `Context` (it uses an `Area`, not the current `Ui`).
pub fn ui(&mut self, ctx: &Context) {
if self.items.is_empty() {
return;
}
let mut dismiss: Option<u64> = None;
egui::Area::new(egui::Id::new("facett-toasts"))
.anchor(Align2::RIGHT_TOP, egui::vec2(-12.0, 12.0))
.interactable(true)
.show(ctx, |ui| {
ui.set_max_width(320.0);
// newest on top
for t in self.items.iter().rev() {
let frame = egui::Frame::default()
.fill(Color32::from_rgb(30, 32, 40))
.inner_margin(8.0)
.stroke(egui::Stroke::new(1.5_f32, t.level.color()));
let resp = frame
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(t.level.icon()).color(t.level.color()).strong(),
);
ui.label(egui::RichText::new(&t.text).color(Color32::from_rgb(220, 224, 232)));
if ui.small_button("✕").clicked() {
dismiss = Some(t.id);
}
});
})
.response;
if t.case.is_some() && resp.interact(egui::Sense::click()).clicked() {
self.clicked_case = t.case.clone();
}
ui.add_space(6.0);
}
});
if let Some(id) = dismiss {
self.dismiss(id);
}
// keep animating while toasts live
ctx.request_repaint();
}
}
// ── The host-facing `Facet` contract ───────────────────────────────────────────
//
// `Toasts::ui` paints into an `Area` anchored on the `Context` (it is an overlay,
// not an inline widget), so the trait's `ui(&mut Ui)` forwards the ui's context.
impl facett_core::Facet for Toasts {
fn title(&self) -> &str {
Toasts::title(self)
}
fn ui(&mut self, ui: &mut egui::Ui) {
Toasts::ui(self, ui.ctx())
}
fn state_json(&self) -> serde_json::Value {
Toasts::state_json(self)
}
/// The **robot drive seam**: `{"push":{"text":..,"level":"Error","ttl":8}}`
/// raises a toast, `{"dismiss":<id>}` closes one, `{"tick":<secs>}` ages the
/// stack (the headless twin of wall-clock time) and `"clear"` empties it.
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
if v == serde_json::json!("clear") {
return self.clear();
}
if let Some(id) = v.get("dismiss").and_then(|s| s.as_u64()) {
return self.dismiss(id);
}
if let Some(dt) = v.get("tick").and_then(|s| s.as_f64()) {
return self.tick(dt as f32);
}
if let Some(p) = v.get("push") {
let text = p.get("text").and_then(|s| s.as_str()).unwrap_or("");
let ttl = p.get("ttl").and_then(|s| s.as_f64()).unwrap_or(4.0) as f32;
let level = match p.get("level").and_then(|s| s.as_str()) {
Some("Error") => Level::Error,
Some("Warn") => Level::Warn,
Some("Success") => Level::Success,
_ => Level::Info,
};
let case = p.get("case").and_then(|s| s.as_str()).map(|s| s.to_string());
self.push_case(text, level, ttl, case);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tick_expires_toasts() {
let mut t = Toasts::new();
t.push("assigned to you", Level::Info, 3.0);
t.push("SLA breach", Level::Error, 1.0);
assert_eq!(t.len(), 2);
t.tick(1.5); // the 1.0s toast expires
assert_eq!(t.len(), 1);
assert_eq!(t.state_json()["toasts"][0]["level"], "Info");
t.tick(2.0);
assert!(t.is_empty());
}
#[test]
fn dismiss_and_clear() {
let mut t = Toasts::new();
let id = t.push("x", Level::Warn, 5.0);
t.push("y", Level::Success, 5.0);
t.dismiss(id);
assert_eq!(t.len(), 1);
t.clear();
assert!(t.is_empty());
}
#[test]
fn state_json_counts() {
let mut t = Toasts::new();
t.push_case("case moved", Level::Success, 4.0, Some("SUPP-1".into()));
let s = t.state_json();
assert_eq!(s["count"], 1);
assert_eq!(s["toasts"][0]["case"], "SUPP-1");
}
}