1use std::time::Duration;
21
22use egui::Color32; use egui_notify::{Anchor, Toasts};
24
25pub const SCREEN_CAP: usize = 3;
27
28pub const AUTO_CLOSE: Duration = Duration::from_secs(8);
30
31pub const MARGIN: (f32, f32) = (20.0, 20.0);
34
35pub const SPACING: f32 = 10.0;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum AlertLevel {
41 #[default]
43 Info,
44 Warning,
46 Error,
48}
49
50impl AlertLevel {
51 pub const fn color(self, theme: crate::tokens::Theme) -> Color32 {
55 let skin = crate::tokens::alert_of_theme(theme);
56 match self {
57 Self::Info => skin.info,
58 Self::Warning => skin.warning,
59 Self::Error => skin.error,
60 }
61 }
62
63 pub const fn label(self) -> &'static str {
65 match self {
66 Self::Info => "预警",
67 Self::Warning => "注意",
68 Self::Error => "故障",
69 }
70 }
71}
72
73pub fn configured() -> Toasts {
75 Toasts::new()
76 .with_anchor(Anchor::BottomRight)
77 .reverse(true)
80 .with_margin(egui::vec2(MARGIN.0, MARGIN.1))
81 .with_spacing(SPACING)
82}
83
84pub fn push(
87 toasts: &mut Toasts,
88 level: AlertLevel,
89 theme: crate::tokens::Theme,
90 text: impl Into<String>,
91) {
92 if toasts.len() >= SCREEN_CAP {
93 toasts.dismiss_oldest_toast();
94 }
95 toasts
96 .custom(
97 format!("{}:{}", level.label(), text.into()),
98 "▎".to_string(),
99 level.color(theme),
100 )
101 .duration(AUTO_CLOSE)
102 .closable(false)
104 .show_progress_bar(false);
105}
106
107#[derive(Default)]
112pub struct ToastsCell {
113 inner: std::sync::Mutex<Option<Toasts>>,
114}
115
116impl ToastsCell {
117 pub fn lock(&self) -> std::sync::MutexGuard<'_, Option<Toasts>> {
119 self.inner
120 .lock()
121 .unwrap_or_else(std::sync::PoisonError::into_inner)
122 }
123}
124
125impl std::fmt::Debug for ToastsCell {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 let n = self.lock().as_ref().map(|t| t.len()).unwrap_or(0);
128 f.debug_struct("ToastsCell")
129 .field("queued", &n)
130 .field("configured", &self.lock().is_some())
131 .finish()
132 }
133}
134
135pub fn drain_and_show(ctx: &egui::Context, shared: &crate::state::UiShared) {
140 let pending = shared.take_alerts();
141 let theme = crate::tokens::theme_of(ctx);
142 let mut cell = shared.toasts();
143 let toasts = cell.get_or_insert_with(configured);
144 for (level, text) in pending {
145 push(toasts, level, theme, text);
146 }
147 toasts.show(ctx);
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
158 fn alert_colors_are_the_python_triplet() {
159 let dark = crate::tokens::Theme::Dark;
160 assert_eq!(AlertLevel::Info.color(dark), crate::tokens::alert::INFO);
161 assert_eq!(
162 AlertLevel::Warning.color(dark),
163 crate::tokens::alert::WARNING
164 );
165 assert_eq!(AlertLevel::Error.color(dark), crate::tokens::alert::ERROR);
166 let light = crate::tokens::Theme::Light;
168 assert_ne!(
169 AlertLevel::Warning.color(light),
170 crate::tokens::alert::WARNING,
171 "浅档沿用深档橙 = 浅底上糊字"
172 );
173 }
174
175 #[test]
177 fn cap_and_auto_close_match_the_python_numbers() {
178 assert_eq!(SCREEN_CAP, 3);
179 assert_eq!(AUTO_CLOSE, Duration::from_secs(8));
180 assert_eq!(SPACING, 10.0);
181 }
182
183 #[test]
185 fn pushing_a_fourth_dismisses_the_oldest_first() {
186 let mut t = configured();
187 for i in 0..3 {
188 push(
189 &mut t,
190 AlertLevel::Info,
191 crate::tokens::Theme::Dark,
192 format!("甲{i}"),
193 );
194 }
195 assert_eq!(t.len(), 3, "三条应当都在(未退场)");
196 push(
197 &mut t,
198 AlertLevel::Error,
199 crate::tokens::Theme::Dark,
200 "第四条",
201 );
202 assert_eq!(
203 t.len(),
204 4,
205 "dismiss 只是置退场状态,真正出列要 show() 跑过动画帧 —— 这里钉的是「关过最旧那条」这个动作"
206 );
207 }
208}