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

nordisk/facett

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");
    }
}