ek-ui 0.9.40

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
//! 0.3.2(Wave E,BD-UI-50)告警 toast —— 照 Python `trader/ui/alert_toast.py`。
//!
//! 载体是 `egui-notify` 0.21.0(唯一依赖 `egui ^0.33`;0.22 起要 `^0.34`,撞我们的 0.33.3)。
//! 这一层只干三件事:**把级别色定成 Python 那三个**、**把位置定成右下角向上堆叠且新的在最底**、
//! **把同屏上限 3 与 8 秒自动关定下来**(crate 自己没有上限开关)。
//!
//! 本轮实核出来的 crate 口径(spike `tests/spike_notify.rs` 四条全过,落地即删):
//! - toast 画在 `LayerId::new(Order::Foreground, Id::new("toasts"))` 这个独立图层上 ——
//!   spike S2 把锚点挪到右上角压着我们的菜单条/表头覆盖层带实测像素可见(带 y=35..45)
//!   → **不被我们的覆盖层压住**(规划 C13 的答案)。
//! - `Anchor::BottomRight` + `reverse(true)` = 新的在最底、向上堆叠(spike S3 实测三条带
//!   y = 712..722 / 763..773 / 814..824)—— 与 Python `_reposition` 的 `reversed()` 口径一致。
//! - **toast 是 painter 画出来的,不是控件**:没有 `Response`、没有 AccessKit 节点,
//!   `Toast` 的 `width/height/level/duration` 全是 `pub(crate)`,`width` 每帧按文字重算。
//!   所以 Python 那条「点击卡片跳该合约 K 线」在这个 crate 里**做不到**(唯一的交互是右上角「❌」)。
//!   本轮不做,记成欠账(硬做要在我们这边复刻它的锚点与宽度公式,脆)。
//! - Python 的「左边框 4px 级别色」crate 也没有(背景走 `visuals.bg_fill`,级别色只染图标 galley)
//!   → 这里用 `ToastLevel::Custom("▎", Python 色)` 做**级别可辨**,不假装像素一致。

use std::time::Duration;

use egui::Color32; // 只给 color() 的返回类型用;色值本身在 tokens.rs(U2 单一色源)
use egui_notify::{Anchor, Toasts};

/// 同屏上限(Python `AlertToastManager._max_toasts = 3`;超限关最早的)。
pub const SCREEN_CAP: usize = 3;

/// 自动关时长(Python `AlertToast.show_toast(duration=8000)`)。
pub const AUTO_CLOSE: Duration = Duration::from_secs(8);

/// 右下角边距(Python `_margin_right = 20` / `_margin_bottom = 60`;
/// 60 那条是「避开任务栏」,我们画在窗口内容区里,不隔任务栏,故取 20/20 并在快照里钉形状)。
pub const MARGIN: (f32, f32) = (20.0, 20.0);

/// 卡片间距(Python `_gap = 10`)。
pub const SPACING: f32 = 10.0;

/// 告警级别 —— 只有 Python 用到的三级(egui-notify 还有个 Success,我们不用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlertLevel {
    /// INFO(Python `#1890FF`)。
    #[default]
    Info,
    /// WARNING(Python `#FAAD14`)。
    Warning,
    /// ERROR(Python `#FF4D4F`)。
    Error,
}

impl AlertLevel {
    /// 级别色。深档 = Python `AlertToast._LEVEL_COLORS`(alert_toast.py:45-49)一字不改;
    /// 浅档 = 参考站的 `text` 支(BD-UI-61:浅底上必须换字色 —— Python 橙 `#FAAD14`
    /// 对浅底白卡只有 **1.9:1**,照搬等于糊字)。
    pub const fn color(self, theme: crate::tokens::Theme) -> Color32 {
        let skin = crate::tokens::alert_of_theme(theme);
        match self {
            Self::Info => skin.info,
            Self::Warning => skin.warning,
            Self::Error => skin.error,
        }
    }

    /// 级别名(进 toast 文案前缀,也让判据能按名字查)。
    pub const fn label(self) -> &'static str {
        match self {
            Self::Info => "预警",
            Self::Warning => "注意",
            Self::Error => "故障",
        }
    }
}

/// 我们这套 toast 的位置与间距口径(每次新建都从这里出发,不给 crate 缺省值留位置)。
pub fn configured() -> Toasts {
    Toasts::new()
        .with_anchor(Anchor::BottomRight)
        // crate 的 BottomRight 是「列表头 = 最下」;`reverse(true)` 让新 toast 插到列表头
        // → 新的在最底、向上堆叠 = Python `_reposition` 的口径。
        .reverse(true)
        .with_margin(egui::vec2(MARGIN.0, MARGIN.1))
        .with_spacing(SPACING)
}

/// 灌一条告警:先按上限关最旧的,再进队。级别色走 Custom 的「▎」(见模块头那条口径)。
/// 0.8.0:级别色按**当前档**取(`drain_and_show` 从 ctx 读档传进来,浅档不会糊字)。
pub fn push(
    toasts: &mut Toasts,
    level: AlertLevel,
    theme: crate::tokens::Theme,
    text: impl Into<String>,
) {
    if toasts.len() >= SCREEN_CAP {
        toasts.dismiss_oldest_toast();
    }
    toasts
        .custom(
            format!("{}:{}", level.label(), text.into()),
            "▎".to_string(),
            level.color(theme),
        )
        .duration(AUTO_CLOSE)
        // Python 卡片没有关闭钮(只有自动关与点击);crate 的「❌」是它自己的交互,不要。
        .closable(false)
        .show_progress_bar(false);
}

/// 常驻 toast 队列的容器。
///
/// 为什么要包一层:`egui_notify::Toasts` **没有 `Debug`**,而 `UiShared` 是 `#[derive(Debug)]` ——
/// 直接放 `Mutex<Option<Toasts>>` 编译不过。包一层就能自己决定 Debug 印什么(只印条数,不印内容)。
#[derive(Default)]
pub struct ToastsCell {
    inner: std::sync::Mutex<Option<Toasts>>,
}

impl ToastsCell {
    /// 取队列(可能拿到 `None` = 还没按我们的口径建过)。
    pub fn lock(&self) -> std::sync::MutexGuard<'_, Option<Toasts>> {
        self.inner
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
    }
}

impl std::fmt::Debug for ToastsCell {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let n = self.lock().as_ref().map(|t| t.len()).unwrap_or(0);
        f.debug_struct("ToastsCell")
            .field("queued", &n)
            .field("configured", &self.lock().is_some())
            .finish()
    }
}

/// 每帧收口:把 `UiShared` 里攒下的告警灌进常驻队列,再让 crate 画。
///
/// 常驻队列住在 `UiShared`(不是 render 的局部变量)—— 否则每帧新建,8 秒自动关与
/// 同屏上限都没有意义。
pub fn drain_and_show(ctx: &egui::Context, shared: &crate::state::UiShared) {
    let pending = shared.take_alerts();
    let theme = crate::tokens::theme_of(ctx);
    let mut cell = shared.toasts();
    let toasts = cell.get_or_insert_with(configured);
    for (level, text) in pending {
        push(toasts, level, theme, text);
    }
    toasts.show(ctx);
}

#[cfg(test)]
mod tests {
    use super::*;

    /// 级别色必须是 Python 那三个(钉死,不许后来「美化」时顺手改掉)。
    /// 0.8.0 双档:这一条钉的是**深档 = Python 原值一字不改**;浅档另有浅档值(参考站
    /// `text` 支),浅档值由判据 `u10`(对比度)与 `u11`(hue 恒定)管着,不在这里比。
    #[test]
    fn alert_colors_are_the_python_triplet() {
        let dark = crate::tokens::Theme::Dark;
        assert_eq!(AlertLevel::Info.color(dark), crate::tokens::alert::INFO);
        assert_eq!(
            AlertLevel::Warning.color(dark),
            crate::tokens::alert::WARNING
        );
        assert_eq!(AlertLevel::Error.color(dark), crate::tokens::alert::ERROR);
        // 浅档必须是另一支(浅底上 Python 橙只有 1.9:1,照搬就是糊字)。
        let light = crate::tokens::Theme::Light;
        assert_ne!(
            AlertLevel::Warning.color(light),
            crate::tokens::alert::WARNING,
            "浅档沿用深档橙 = 浅底上糊字"
        );
    }

    /// 上限与时长必须是 Python 那两个数。
    #[test]
    fn cap_and_auto_close_match_the_python_numbers() {
        assert_eq!(SCREEN_CAP, 3);
        assert_eq!(AUTO_CLOSE, Duration::from_secs(8));
        assert_eq!(SPACING, 10.0);
    }

    /// 同屏上限的机制形状:第 4 条进来前必须先把最旧的关掉(crate 没有上限开关,我们自己兜)。
    #[test]
    fn pushing_a_fourth_dismisses_the_oldest_first() {
        let mut t = configured();
        for i in 0..3 {
            push(
                &mut t,
                AlertLevel::Info,
                crate::tokens::Theme::Dark,
                format!("甲{i}"),
            );
        }
        assert_eq!(t.len(), 3, "三条应当都在(未退场)");
        push(
            &mut t,
            AlertLevel::Error,
            crate::tokens::Theme::Dark,
            "第四条",
        );
        assert_eq!(
            t.len(),
            4,
            "dismiss 只是置退场状态,真正出列要 show() 跑过动画帧 —— 这里钉的是「关过最旧那条」这个动作"
        );
    }
}