Skip to main content

ek_ui/
toast.rs

1//! 0.3.2(Wave E,BD-UI-50)告警 toast —— 照 Python `trader/ui/alert_toast.py`。
2//!
3//! 载体是 `egui-notify` 0.21.0(唯一依赖 `egui ^0.33`;0.22 起要 `^0.34`,撞我们的 0.33.3)。
4//! 这一层只干三件事:**把级别色定成 Python 那三个**、**把位置定成右下角向上堆叠且新的在最底**、
5//! **把同屏上限 3 与 8 秒自动关定下来**(crate 自己没有上限开关)。
6//!
7//! 本轮实核出来的 crate 口径(spike `tests/spike_notify.rs` 四条全过,落地即删):
8//! - toast 画在 `LayerId::new(Order::Foreground, Id::new("toasts"))` 这个独立图层上 ——
9//!   spike S2 把锚点挪到右上角压着我们的菜单条/表头覆盖层带实测像素可见(带 y=35..45)
10//!   → **不被我们的覆盖层压住**(规划 C13 的答案)。
11//! - `Anchor::BottomRight` + `reverse(true)` = 新的在最底、向上堆叠(spike S3 实测三条带
12//!   y = 712..722 / 763..773 / 814..824)—— 与 Python `_reposition` 的 `reversed()` 口径一致。
13//! - **toast 是 painter 画出来的,不是控件**:没有 `Response`、没有 AccessKit 节点,
14//!   `Toast` 的 `width/height/level/duration` 全是 `pub(crate)`,`width` 每帧按文字重算。
15//!   所以 Python 那条「点击卡片跳该合约 K 线」在这个 crate 里**做不到**(唯一的交互是右上角「❌」)。
16//!   本轮不做,记成欠账(硬做要在我们这边复刻它的锚点与宽度公式,脆)。
17//! - Python 的「左边框 4px 级别色」crate 也没有(背景走 `visuals.bg_fill`,级别色只染图标 galley)
18//!   → 这里用 `ToastLevel::Custom("▎", Python 色)` 做**级别可辨**,不假装像素一致。
19
20use std::time::Duration;
21
22use egui::Color32; // 只给 color() 的返回类型用;色值本身在 tokens.rs(U2 单一色源)
23use egui_notify::{Anchor, Toasts};
24
25/// 同屏上限(Python `AlertToastManager._max_toasts = 3`;超限关最早的)。
26pub const SCREEN_CAP: usize = 3;
27
28/// 自动关时长(Python `AlertToast.show_toast(duration=8000)`)。
29pub const AUTO_CLOSE: Duration = Duration::from_secs(8);
30
31/// 右下角边距(Python `_margin_right = 20` / `_margin_bottom = 60`;
32/// 60 那条是「避开任务栏」,我们画在窗口内容区里,不隔任务栏,故取 20/20 并在快照里钉形状)。
33pub const MARGIN: (f32, f32) = (20.0, 20.0);
34
35/// 卡片间距(Python `_gap = 10`)。
36pub const SPACING: f32 = 10.0;
37
38/// 告警级别 —— 只有 Python 用到的三级(egui-notify 还有个 Success,我们不用)。
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum AlertLevel {
41    /// INFO(Python `#1890FF`)。
42    #[default]
43    Info,
44    /// WARNING(Python `#FAAD14`)。
45    Warning,
46    /// ERROR(Python `#FF4D4F`)。
47    Error,
48}
49
50impl AlertLevel {
51    /// 级别色。深档 = Python `AlertToast._LEVEL_COLORS`(alert_toast.py:45-49)一字不改;
52    /// 浅档 = 参考站的 `text` 支(BD-UI-61:浅底上必须换字色 —— Python 橙 `#FAAD14`
53    /// 对浅底白卡只有 **1.9:1**,照搬等于糊字)。
54    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    /// 级别名(进 toast 文案前缀,也让判据能按名字查)。
64    pub const fn label(self) -> &'static str {
65        match self {
66            Self::Info => "预警",
67            Self::Warning => "注意",
68            Self::Error => "故障",
69        }
70    }
71}
72
73/// 我们这套 toast 的位置与间距口径(每次新建都从这里出发,不给 crate 缺省值留位置)。
74pub fn configured() -> Toasts {
75    Toasts::new()
76        .with_anchor(Anchor::BottomRight)
77        // crate 的 BottomRight 是「列表头 = 最下」;`reverse(true)` 让新 toast 插到列表头
78        // → 新的在最底、向上堆叠 = Python `_reposition` 的口径。
79        .reverse(true)
80        .with_margin(egui::vec2(MARGIN.0, MARGIN.1))
81        .with_spacing(SPACING)
82}
83
84/// 灌一条告警:先按上限关最旧的,再进队。级别色走 Custom 的「▎」(见模块头那条口径)。
85/// 0.8.0:级别色按**当前档**取(`drain_and_show` 从 ctx 读档传进来,浅档不会糊字)。
86pub 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        // Python 卡片没有关闭钮(只有自动关与点击);crate 的「❌」是它自己的交互,不要。
103        .closable(false)
104        .show_progress_bar(false);
105}
106
107/// 常驻 toast 队列的容器。
108///
109/// 为什么要包一层:`egui_notify::Toasts` **没有 `Debug`**,而 `UiShared` 是 `#[derive(Debug)]` ——
110/// 直接放 `Mutex<Option<Toasts>>` 编译不过。包一层就能自己决定 Debug 印什么(只印条数,不印内容)。
111#[derive(Default)]
112pub struct ToastsCell {
113    inner: std::sync::Mutex<Option<Toasts>>,
114}
115
116impl ToastsCell {
117    /// 取队列(可能拿到 `None` = 还没按我们的口径建过)。
118    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
135/// 每帧收口:把 `UiShared` 里攒下的告警灌进常驻队列,再让 crate 画。
136///
137/// 常驻队列住在 `UiShared`(不是 render 的局部变量)—— 否则每帧新建,8 秒自动关与
138/// 同屏上限都没有意义。
139pub 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    /// 级别色必须是 Python 那三个(钉死,不许后来「美化」时顺手改掉)。
155    /// 0.8.0 双档:这一条钉的是**深档 = Python 原值一字不改**;浅档另有浅档值(参考站
156    /// `text` 支),浅档值由判据 `u10`(对比度)与 `u11`(hue 恒定)管着,不在这里比。
157    #[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        // 浅档必须是另一支(浅底上 Python 橙只有 1.9:1,照搬就是糊字)。
167        let light = crate::tokens::Theme::Light;
168        assert_ne!(
169            AlertLevel::Warning.color(light),
170            crate::tokens::alert::WARNING,
171            "浅档沿用深档橙 = 浅底上糊字"
172        );
173    }
174
175    /// 上限与时长必须是 Python 那两个数。
176    #[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    /// 同屏上限的机制形状:第 4 条进来前必须先把最旧的关掉(crate 没有上限开关,我们自己兜)。
184    #[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}