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    pub const fn color(self) -> Color32 {
53        match self {
54            Self::Info => crate::tokens::alert::INFO,
55            Self::Warning => crate::tokens::alert::WARNING,
56            Self::Error => crate::tokens::alert::ERROR,
57        }
58    }
59
60    /// 级别名(进 toast 文案前缀,也让判据能按名字查)。
61    pub const fn label(self) -> &'static str {
62        match self {
63            Self::Info => "预警",
64            Self::Warning => "注意",
65            Self::Error => "故障",
66        }
67    }
68}
69
70/// 我们这套 toast 的位置与间距口径(每次新建都从这里出发,不给 crate 缺省值留位置)。
71pub fn configured() -> Toasts {
72    Toasts::new()
73        .with_anchor(Anchor::BottomRight)
74        // crate 的 BottomRight 是「列表头 = 最下」;`reverse(true)` 让新 toast 插到列表头
75        // → 新的在最底、向上堆叠 = Python `_reposition` 的口径。
76        .reverse(true)
77        .with_margin(egui::vec2(MARGIN.0, MARGIN.1))
78        .with_spacing(SPACING)
79}
80
81/// 灌一条告警:先按上限关最旧的,再进队。级别色走 Custom 的「▎」(见模块头那条口径)。
82pub fn push(toasts: &mut Toasts, level: AlertLevel, text: impl Into<String>) {
83    if toasts.len() >= SCREEN_CAP {
84        toasts.dismiss_oldest_toast();
85    }
86    toasts
87        .custom(
88            format!("{}:{}", level.label(), text.into()),
89            "▎".to_string(),
90            level.color(),
91        )
92        .duration(AUTO_CLOSE)
93        // Python 卡片没有关闭钮(只有自动关与点击);crate 的「❌」是它自己的交互,不要。
94        .closable(false)
95        .show_progress_bar(false);
96}
97
98/// 常驻 toast 队列的容器。
99///
100/// 为什么要包一层:`egui_notify::Toasts` **没有 `Debug`**,而 `UiShared` 是 `#[derive(Debug)]` ——
101/// 直接放 `Mutex<Option<Toasts>>` 编译不过。包一层就能自己决定 Debug 印什么(只印条数,不印内容)。
102#[derive(Default)]
103pub struct ToastsCell {
104    inner: std::sync::Mutex<Option<Toasts>>,
105}
106
107impl ToastsCell {
108    /// 取队列(可能拿到 `None` = 还没按我们的口径建过)。
109    pub fn lock(&self) -> std::sync::MutexGuard<'_, Option<Toasts>> {
110        self.inner
111            .lock()
112            .unwrap_or_else(std::sync::PoisonError::into_inner)
113    }
114}
115
116impl std::fmt::Debug for ToastsCell {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        let n = self.lock().as_ref().map(|t| t.len()).unwrap_or(0);
119        f.debug_struct("ToastsCell")
120            .field("queued", &n)
121            .field("configured", &self.lock().is_some())
122            .finish()
123    }
124}
125
126/// 每帧收口:把 `UiShared` 里攒下的告警灌进常驻队列,再让 crate 画。
127///
128/// 常驻队列住在 `UiShared`(不是 render 的局部变量)—— 否则每帧新建,8 秒自动关与
129/// 同屏上限都没有意义。
130pub fn drain_and_show(ctx: &egui::Context, shared: &crate::state::UiShared) {
131    let pending = shared.take_alerts();
132    let mut cell = shared.toasts();
133    let toasts = cell.get_or_insert_with(configured);
134    for (level, text) in pending {
135        push(toasts, level, text);
136    }
137    toasts.show(ctx);
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    /// 级别色必须是 Python 那三个(钉死,不许后来「美化」时顺手改掉)。
145    #[test]
146    fn alert_colors_are_the_python_triplet() {
147        assert_eq!(AlertLevel::Info.color(), crate::tokens::alert::INFO);
148        assert_eq!(AlertLevel::Warning.color(), crate::tokens::alert::WARNING);
149        assert_eq!(AlertLevel::Error.color(), crate::tokens::alert::ERROR);
150    }
151
152    /// 上限与时长必须是 Python 那两个数。
153    #[test]
154    fn cap_and_auto_close_match_the_python_numbers() {
155        assert_eq!(SCREEN_CAP, 3);
156        assert_eq!(AUTO_CLOSE, Duration::from_secs(8));
157        assert_eq!(SPACING, 10.0);
158    }
159
160    /// 同屏上限的机制形状:第 4 条进来前必须先把最旧的关掉(crate 没有上限开关,我们自己兜)。
161    #[test]
162    fn pushing_a_fourth_dismisses_the_oldest_first() {
163        let mut t = configured();
164        for i in 0..3 {
165            push(&mut t, AlertLevel::Info, format!("甲{i}"));
166        }
167        assert_eq!(t.len(), 3, "三条应当都在(未退场)");
168        push(&mut t, AlertLevel::Error, "第四条");
169        assert_eq!(
170            t.len(),
171            4,
172            "dismiss 只是置退场状态,真正出列要 show() 跑过动画帧 —— 这里钉的是「关过最旧那条」这个动作"
173        );
174    }
175}