use brep_render::engine_state::NoticeSeverity as Severity;
use eframe::egui;
use std::collections::HashMap;
const TTL: f64 = 6.0;
const MIN_SHOWN: f64 = 1.5;
pub(crate) const MAX_VISIBLE: usize = 3;
const MAX_KEPT: usize = 12;
const CARD_WIDTH: f32 = 360.0;
const CARD_MIN_WIDTH: f32 = 240.0;
const MARGIN: f32 = 12.0;
const GAP: f32 = 6.0;
#[derive(Debug, Clone, PartialEq)]
struct Toast {
text: String,
severity: Severity,
count: u32,
born: f64,
superseded: bool,
}
#[derive(Default)]
pub struct Toasts {
items: Vec<Toast>,
document: Option<(u64, Option<u64>)>,
hits: HashMap<String, egui::Rect>,
}
impl Toasts {
pub fn dismiss_all(&mut self) -> usize {
let n = self.items.len();
self.items.clear();
self.hits.clear();
n
}
pub fn new() -> Self {
Self::default()
}
pub fn extend(&mut self, messages: impl IntoIterator<Item = (Severity, String)>, now: f64) {
for (severity, text) in messages {
let count = match self.items.iter().position(|t| t.text == text) {
Some(at) => self.items.remove(at).count.saturating_add(1),
None => 1,
};
self.items.push(Toast { text, severity, count, born: now, superseded: false });
}
let overflow = self.items.len().saturating_sub(MAX_KEPT);
if overflow > 0 {
self.items.drain(0..overflow);
}
}
pub fn note_document(&mut self, document: u64, key: Option<u64>, now: f64) {
let previous = self.document.replace((document, key));
if matches!(previous, Some((d, k)) if d == document && k != key) {
for toast in self.items.iter_mut().filter(|t| t.born < now) {
toast.superseded = true;
}
}
}
fn retire(&mut self, now: f64) {
self.items.retain(|t| {
let age = now - t.born;
age < TTL && !(t.superseded && age >= MIN_SHOWN)
});
}
fn folded(&self) -> usize {
self.items.len().saturating_sub(MAX_VISIBLE)
}
pub fn texts_json(&self) -> String {
serde_json::Value::Array(
self.items.iter().map(|t| serde_json::Value::String(t.text.clone())).collect(),
)
.to_string()
}
pub fn state_json(&self) -> String {
let folded = self.folded();
serde_json::json!({
"cards": self.items.iter().enumerate().map(|(i, t)| serde_json::json!({
"text": t.text,
"severity": severity_name(t.severity),
"count": t.count,
"shown": i >= folded,
"superseded": t.superseded,
})).collect::<Vec<_>>(),
"shown": self.items.len() - folded,
"folded": folded,
})
.to_string()
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
pub fn show(&mut self, ctx: &egui::Context, canvas: Option<egui::Rect>, floor: f32) {
let now = ctx.input(|i| i.time);
self.retire(now);
self.hits.clear();
if self.items.is_empty() {
return;
}
ctx.request_repaint();
let (pivot, x) = match canvas {
Some(canvas) => (egui::Align2::LEFT_BOTTOM, canvas.left() + MARGIN),
None => (egui::Align2::CENTER_BOTTOM, ctx.content_rect().center().x),
};
let mut bottom = canvas.map_or(floor, |c| c.bottom().min(floor)) - MARGIN;
let folded = self.folded();
let mut dismissed: Option<usize> = None;
for index in (folded..self.items.len()).rev() {
let toast = &self.items[index];
let text = match toast.count {
1 => toast.text.clone(),
n => format!("{} ×{n}", toast.text),
};
let palette = Palette::of(toast.severity);
let (rect, clicked) = card(ctx, egui::Id::new(("brep-toast", index)), pivot, egui::pos2(x, bottom), palette, |ui| {
ui.set_min_width(CARD_MIN_WIDTH);
ui.set_max_width(CARD_WIDTH);
ui.add(egui::Label::new(egui::RichText::new(text).color(palette.text)).selectable(false));
});
self.hits.insert(format!("toast:{}", index - folded), rect);
if clicked {
dismissed = Some(index);
}
bottom = rect.top() - GAP;
}
if folded > 0 {
let label = format!("+{folded} older");
let palette = Palette::CHIP;
let (rect, clicked) = card(ctx, egui::Id::new("brep-toast-more"), pivot, egui::pos2(x, bottom), palette, |ui| {
ui.add(egui::Label::new(egui::RichText::new(label).small().color(palette.text)).selectable(false));
});
self.hits.insert("toast:more".into(), rect);
if clicked {
self.items.drain(0..folded);
return;
}
}
if let Some(index) = dismissed {
self.items.remove(index);
}
}
}
pub static HIT_KEYS: &[crate::automation::hit_keys::HitKeyDoc] = &[
crate::automation::hit_keys::HitKeyDoc { panel: "toasts", prefix: "toast:more", meaning: "the `+N older` chip above the stack; a click dismisses the folded cards", command: None },
crate::automation::hit_keys::HitKeyDoc { panel: "toasts", prefix: "toast:", meaning: "a drawn toast card, 0 at the top of the stack; a click dismisses it", command: None },
];
fn severity_name(severity: Severity) -> &'static str {
match severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Palette {
fill: egui::Color32,
stroke: egui::Color32,
text: egui::Color32,
}
impl Palette {
const ERROR: Self = Self {
fill: egui::Color32::from_rgb(0x3a, 0x24, 0x24),
stroke: egui::Color32::from_rgb(0xff, 0x6b, 0x6b),
text: egui::Color32::from_rgb(0xff, 0x9b, 0x9b),
};
const WARNING: Self = Self {
fill: egui::Color32::from_rgb(0x38, 0x30, 0x1e),
stroke: egui::Color32::from_rgb(0xe8, 0xb3, 0x4a),
text: egui::Color32::from_rgb(0xf5, 0xd4, 0x8f),
};
const INFO: Self = Self {
fill: egui::Color32::from_rgb(0x1f, 0x33, 0x29),
stroke: egui::Color32::from_rgb(0x5c, 0xc0, 0x86),
text: egui::Color32::from_rgb(0xb4, 0xe8, 0xc8),
};
const CHIP: Self = Self {
fill: egui::Color32::from_rgb(0x2a, 0x2c, 0x30),
stroke: egui::Color32::from_rgb(0x70, 0x76, 0x80),
text: egui::Color32::from_rgb(0xc8, 0xcc, 0xd2),
};
fn of(severity: Severity) -> Self {
match severity {
Severity::Error => Self::ERROR,
Severity::Warning => Self::WARNING,
Severity::Info => Self::INFO,
}
}
}
fn card(
ctx: &egui::Context,
id: egui::Id,
pivot: egui::Align2,
at: egui::Pos2,
palette: Palette,
add: impl FnOnce(&mut egui::Ui),
) -> (egui::Rect, bool) {
let response = egui::Area::new(id)
.order(egui::Order::Foreground)
.pivot(pivot)
.fixed_pos(at)
.sense(egui::Sense::click())
.show(ctx, |ui| {
egui::Frame::popup(ui.style())
.fill(palette.fill)
.stroke(egui::Stroke::new(1.0, palette.stroke))
.show(ui, add);
})
.response;
let response = response.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text("Click to dismiss");
(response.rect, response.clicked())
}