ek-ui 0.6.2

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
//! 设计令牌层(0.2.30,BD-UI-46)。
//!
//! 为什么要有这一层:0.2.29 之前「颜色」散在两个地方 —— 主题函数里一份(五套
//! `Visuals`),渲染代码里另一份(`from_rgb` 直绘 50 多处:多红 / 空绿 / 警告橙 /
//! 错误深红 / 成功深绿 / 次级灰蓝 / 灰)。同一支语义色在不同表里各写各的,改一处
//! 不会传到另一处;而且「涨跌色」这种**必须恒定**的东西没有唯一源。
//!
//! 0.2.30 起规矩写死:**`src/` 里除本模块,`from_rgb(` 出现次数必须为 0**(机械钉
//! `u2_no_hardcoded_colors` 挡着)。所有色、字号、间距、控件尺寸、圆角从这里出。
//!
//! 主题只有一套(裁定 8 → 0.4.1 四期改判,BD-UI-57):**shadcn/ui Nova 深色**。
//!
//! 裁定链(2026-10-22):用户「界面风格参考 egui-shadcn」→ 先把两个同名库都装下来实测
//! 才动手:① crates.io 的 `egui-shadcn 0.5.0`(= FerrisMind/shadcn-rs)与用户先前给的
//! `pjankiewicz/egui-shadcn`(0.1.0,不在 crates.io 上)不是同一个项目;② 两家的
//! `Button` / tab 触发器 / `Select` 触发器都只把文字 `painter` 画出去,AccessKit 语义树
//! 里查不到(探针实测:「钮A」AccessKit = 0、「委托E」「下拉D」= 0,对照原生 egui 按钮 = 1)
//! —— 换控件 = 判据面整片失效。→ **裁定 = 只搬皮肤(色板 + 圆角 + 1px 描边),不引库**:
//! 控件层、表格层(egui_table 0.7.0)、判据面结构一律不动。
//!
//! 色值源 = `ColorPalette::shadcn_dark(ShadcnBaseColor::Neutral)`(库内自带 OKLCH→sRGB
//! 换算,`Theme::default()` 就是这一套),本机跑探针把 26 支色读出来核对(见 DESIGN
//! BD-UI-57 的对照表)。库把 `border` / `input` 定义为 `Oklch::with_alpha(1.0, 0, 0,
//! 0.10)` / `0.15`(库 tokens.rs:403-404),换算出来是 `#1A1A1A_1A` / `#262626_26` ——
//! **这不是库预乘错了**:egui 的 `Color32` 本身就是**预乘 alpha** 存储(ecolor-0.33.3
//! `color32.rs:8`「0-255 gamma space sRGBA with _premultiplied alpha_」),白 10% 存成
//! `(26,26,26,26)` 正是它的正确形态。我们写源值用
//! `Color32::from_rgba_unmultiplied_const(0xFF, 0xFF, 0xFF, 26)`(const 版,`pub const`
//! 用得了;非 const 版在 const 上下文里报 E0015),egui 自己换算出的就是同一支。
//!
//! 旧主题 = Qt深色 QDarkStyle 3.2.3 复刻(依据 = 已安装的 QDarkStyle 3.2.3
//! `dark/palette.py` 色板 + `dark/darkstyle.qss` 规格 2216 行实测:radius 4px 出现 48 次、
//! 描边 1px、padding 以 2px 为主;源 = `enkeep_trade/enkeep_trade/trader/ui/qt.py:102`
//! `qapp.setStyleSheet(qdarkstyle.load_stylesheet(qt_api="pyside6"))`)。旧值逐支留在下面
//! 每条令牌的注释里,CHANGELOG 0.4.1 四期有整张对照表,可回溯。

use egui::{Color32, CornerRadius, FontId, Style, TextStyle, Vec2, Visuals};

/// shadcn/ui Nova 深色色板(`ColorPalette::shadcn_dark(Neutral)` 实测值)。
/// **全仓唯一允许写 `from_rgb(` / `from_rgba_unmultiplied(` 的地方。**
/// 令牌名沿用角色名(BG* = 底档、TEXT* = 文字档、ACCENT* = 强调档)—— 换的是值不是
/// 名字,渲染侧的取色口径不变。
pub mod shadcn {
    use super::*;
    /// `background` `#0A0A0A` —— 窗底 / 容器底 / 输入框底(shadcn 也是所有容器同底,
    /// 靠 1px `border` 描边区分)。旧 qdark Gray.B10 `#19232D`。
    pub const BG1: Color32 = Color32::from_rgb(0x0A, 0x0A, 0x0A);
    /// `card` `#171717` —— 比背景亮一档的次级底(= `faint_bg_color`)。旧 Gray.B20 `#293544`。
    pub const BG2: Color32 = Color32::from_rgb(0x17, 0x17, 0x17);
    /// `secondary` = `muted` `#262626` —— 表头带底、未选中 tab 头底、按钮底。
    /// 旧 Gray.B40 `#455364`;旧口径这一支**同时**是描边色(带底 = 线色),新口径拆开
    /// → 描边走下面新的 `BORDER`。
    pub const BG4: Color32 = Color32::from_rgb(0x26, 0x26, 0x26);
    /// `accent` `#404040` —— hover 底、选中 tab 底。旧 Gray.B50 `#54687A`。
    pub const BG5: Color32 = Color32::from_rgb(0x40, 0x40, 0x40);
    /// pressed / checked `#525252` —— **本仓合成色**:shadcn 色板没有 pressed 这一档,
    /// 沿同一支中性阶往上取一档(neutral-600)。旧 Gray.B60 `#60798B`。
    pub const BG6: Color32 = Color32::from_rgb(0x52, 0x52, 0x52);
    /// `foreground` `#FAFAFA` —— 正文 / 按钮文字。旧 Gray.B130 `#DFE1E2`。
    pub const TEXT1: Color32 = Color32::from_rgb(0xFA, 0xFA, 0xFA);
    /// `muted-foreground` `#A1A1A1` —— 次级文本。旧 Gray.B90 `#ACB1B6`。
    pub const TEXT3: Color32 = Color32::from_rgb(0xA1, 0xA1, 0xA1);
    /// 选中行 / 选中文本底 `#525252`(neutral-600)。旧 Blue.B40 `#346792`:Nova 色板里
    /// 没有彩色强调,「选中」改由「比 hover 再亮一档的底」表达(蓝档退役)。
    ///
    /// 为什么不是照抄库的 `selected_bg`:库自己 `table.rs:166-167` 是
    /// `muted.gamma_multiply(0.5)`(hover)/ `0.7`(selected),即 `#131313` / `#1A1A1A`
    /// —— 那是给「透明表体浮在 `card` 上」设计的弱档;我们的表体浮在 `background`
    /// `#0A0A0A` 上,照抄会糊成一片。搬的是库的**角色次序**(hover 弱于选中),值取
    /// 我们色板里够得着的两档:`accent` `#404040`(hover)→ `#525252`(选中)。
    /// 与 `BG6`(pressed/checked)同值是**有意**的:两者不会落在同一类控件上
    /// (按下的按钮 vs 选中的行)。
    pub const ACCENT2: Color32 = Color32::from_rgb(0x52, 0x52, 0x52);
    /// `primary` `#E5E5E5` —— 选中 tab 的 3px 下划线、链接色、分隔条拖拽/悬停高亮线。
    /// 旧 Blue.B70 `#259AE9`。
    pub const ACCENT4: Color32 = Color32::from_rgb(0xE5, 0xE5, 0xE5);
    /// `border` = 白 10%(源 `oklch(1 0 0 / 10%)`)—— **一切描边**:窗框、分隔线、
    /// 表头带下边线、网格线、行线、控件边框。旧口径没有这一支独立令牌(描边 = BG4 同色)。
    /// egui 预乘存储后 = `#1A1A1A_1A`,与库实测值同一支。
    pub const BORDER: Color32 = Color32::from_rgba_unmultiplied_const(0xFF, 0xFF, 0xFF, 26);
}

/// 语义色:**跨界面恒定,不随主题漂移**(金融语义不玩配色)。0.2.29 之前这些散在
/// 渲染代码里,现在这里是唯一源。红涨绿跌 / 买红卖绿是同两支,不另造。
/// 0.3.2(Wave E,BD-UI-50)告警 toast 三级色 —— 值就是 Python `AlertToast._LEVEL_COLORS`
/// (alert_toast.py:45-49):ERROR `#FF4D4F` / WARNING `#FAAD14` / INFO `#1890FF`。
/// 色源只这一处(U2 单一色源钉:src/ 里 `Color32::from_rgb(` 只许出现在 tokens.rs)。
pub mod alert {
    use egui::Color32;

    /// Python INFO `#1890FF`。
    pub const INFO: Color32 = Color32::from_rgb(0x18, 0x90, 0xFF);
    /// Python WARNING `#FAAD14`。
    pub const WARNING: Color32 = Color32::from_rgb(0xFA, 0xAD, 0x14);
    /// Python ERROR `#FF4D4F`。
    pub const ERROR: Color32 = Color32::from_rgb(0xFF, 0x4D, 0x4F);
}

pub mod semantic {
    use super::*;
    /// 涨 / 多头 / 买 —— 红。
    pub const RISE: Color32 = Color32::from_rgb(0xE0, 0x50, 0x50);
    /// 跌 / 空头 / 卖 —— 绿。
    pub const FALL: Color32 = Color32::from_rgb(0x2E, 0x9E, 0x2E);
    /// 警告 / 待办 / 「先去做某事」提示 —— 橙。
    pub const WARN: Color32 = Color32::from_rgb(0xE0, 0x88, 0x10);
    /// 错误 / 失败 / 隔离 —— 深红(比 RISE 沉一档,免得把「跌」和「错」混成同一支)。
    pub const ERROR: Color32 = Color32::from_rgb(0xC0, 0x30, 0x30);
    /// 成功 / 已启动 / 已连接 —— 深绿(比 FALL 沉一档,同理)。
    pub const OK: Color32 = Color32::from_rgb(0x2E, 0x7D, 0x32);
    /// 状态类信息(总线状态、来源、中性提示)—— 淡蓝灰。
    pub const INFO: Color32 = Color32::from_rgb(0x9A, 0xA8, 0xC0);
    /// 次级 / 弱化 / 口径说明 —— 中性灰。
    pub const MUTED: Color32 = Color32::from_rgb(0x88, 0x88, 0x88);
}

/// 字号阶梯(BD-UI-32 R1 用户实看裁定「整体字偏小」,对照 Python 版;
/// egui 缺省 13 / 12 / 18 / 12)。三档层级是毛病 7「无视觉层级」的解法:
/// 标题 / 正文 / 次级必须**肉眼可分**,不是只换个字重。
pub const FONT_HEADING: f32 = 22.0;
pub const FONT_BODY: f32 = 16.0;
pub const FONT_SMALL: f32 = 14.0;
pub const FONT_MONO: f32 = 15.0;

/// 控件与间距规格(shadcn 口径:描边 1px;圆角两档 = `--radius` 10px 给卡片 / 窗 / 菜单,
/// 控件取 `calc(var(--radius) - 2px)` = 8px —— 库内 `DEFAULT_RADIUS` 刻度实测
/// `r1 4 / r2 6 / r3 8 / r4 10 / r5 12 / r6 16`,`ControlSize::Sm/Md/Lg` 的控件圆角
/// 正是 6 / 8 / 10。旧 qss 口径是一律 4px,描边 1px 不变)。
pub const CORNER_CARD: u8 = 10;
pub const CORNER_WIDGET: u8 = 8;
pub const STROKE_WIDTH: f32 = 1.0;
/// 行内水平间距 / 垂直行间距(egui 缺省 8 / 4;垂直 4 在 16px 字号下太挤)。
pub const SPACING_ITEM_X: f32 = 8.0;
pub const SPACING_ITEM_Y: f32 = 6.0;
/// 控件目标高度(16px 字号 + qss 的 2px padding 上下 + 描边)。
pub const INTERACT_HEIGHT: f32 = 26.0;
/// 滚动条粗细(0.4.1 七期 BD-UI-60:10 → 5,用户裁定「尽可能细一点」)。
pub const SCROLL_BAR_WIDTH: f32 = 5.0;
/// 分组内边距(`Frame::group` 用)。
pub const FRAME_INNER: f32 = 8.0;
/// 组与组之间、区块之间的呼吸位(`UiExt::space_group` 用)。
pub const GROUP_GAP: f32 = 12.0;

/// 布局尺寸(0.4.1 BD-UI-54 回归原生容器:比例常量退役,回到面板的**像素**缺省值,
/// 与 0.3.x 同值 —— dock 时代那对比例常量 `MARKET_PANE_FRACTION`/`ORDER_PANE_FRACTION`
/// 随 egui_dock 一起退役)。
///
/// - 行情带缺省高 `240` = Python `v_splitter.setSizes([240, 560])` 的上带(面板可拖,
///   30:70 只是初值);
/// - 下单侧缺省宽 `300` = Python `TradingWidget` 的 `setFixedWidth(300)`
///   (`widget.py:1748`,定死不是建议)。
pub const MARKET_BAND_HEIGHT: f32 = 240.0;
pub const ORDER_SIDE_WIDTH: f32 = 300.0;

/// 字号阶梯 + 间距 + 控件尺寸 + 主题视觉,一次装好。
/// 真实 App、独立子窗(immediate 共享同一 Context)、kittest 三处同源。
pub fn apply(style: &mut Style) {
    style
        .text_styles
        .insert(TextStyle::Body, FontId::proportional(FONT_BODY));
    style
        .text_styles
        .insert(TextStyle::Button, FontId::proportional(FONT_BODY));
    style
        .text_styles
        .insert(TextStyle::Small, FontId::proportional(FONT_SMALL));
    style
        .text_styles
        .insert(TextStyle::Heading, FontId::proportional(FONT_HEADING));
    style
        .text_styles
        .insert(TextStyle::Monospace, FontId::monospace(FONT_MONO));
    style.spacing.item_spacing = Vec2::new(SPACING_ITEM_X, SPACING_ITEM_Y);
    style.spacing.interact_size = Vec2::new(40.0, INTERACT_HEIGHT);
    // 滚动条形态(0.4.1 七期 BD-UI-60,用户裁定「滚动条太粗了,尽可能细一点,然后暗一些」):
    // - 细:`bar_width` 10 → 5(旧值 10 是 0.2.29 定的,实测横向滚动条轨道 10 px 高)。
    // - 暗:`foreground_color` 从 true 改 false。true 时把手取 `visuals.fg_stroke.color`
    //   = TEXT1 `#FAFAFA`(全调色板最亮的一支,scroll_area.rs:1421-1425);false 时取
    //   `widgets.inactive.bg_fill` = BG4 `#262626`,hover → BG5、拖 → BG6(递进反馈仍在)。
    // - 占位口径不变(`floating = false`):Qt 的 `QScrollBar` 是占位的,不覆盖内容。
    style.spacing.scroll = egui::style::ScrollStyle {
        bar_width: SCROLL_BAR_WIDTH,
        bar_inner_margin: 2.0,
        ..egui::style::ScrollStyle::solid()
    };
}

/// shadcn Nova 深色视觉(唯一主题)。映射口径逐条对过 shadcn 的 token 用法,不是凭印象:
/// - 页面 / 面板 / 输入框底 = `background` `#0A0A0A`(shadcn 也是所有容器同底,靠 1px
///   `border` 描边区分,所以 `window_fill` 与 `panel_fill` 同值 —— 与旧 qdark 同构,
///   不是偷懒)。`faint_bg_color` = `card` `#171717`。
/// - 控件底 = `secondary` `#262626` → hover `accent` `#404040` → pressed `#525252`
///   (pressed 是本仓沿同一支中性阶合成的下一档,shadcn 色板没有这一支)。描边一律
///   `border`(白 10%):旧口径「`bg_stroke` 与 `bg_fill` 同色 = 视觉无边」随 BG4 拆分
///   一起退役 —— Nova 的控件是**带 1px 描边**的。
/// - expansion 一律 0.0:shadcn hover 只换色不涨块,egui 缺省 hover/active 涨 1px。
/// - fg_stroke 宽度一律 1.0:shadcn hover/pressed 不加重文字,egui 缺省 1.5 / 2.0。
/// - 选中行与选中文本 = `ACCENT2` `#525252`,比 hover(`accent` `#404040`)再亮一档:
///   Nova 没有彩色强调,「选中」由「比 hover 再亮一档的底」表达。选中 tab 的「BG4 未选中
///   底 / BG5 选中底 + ACCENT4 下划线」egui `Visuals` 里没有对应字段 → 由渲染侧
///   `tab_label` 直绘(取色从这份 visuals 派生;0.4.0 曾短暂移交 dock,0.4.1 拆 dock 后
///   自绘归位,判据 s67/v5)。
/// - `selection.stroke.color` 会被 egui 当**选中项文字色**用(style.rs:374)→ 取 TEXT1。
/// - **不搬的东西(记死,免得日后被当成漏)**:库的 `ControlSize` 字号档 13 / 14 / 15
///   —— 我们的字号阶梯 22 / 16 / 14 是 BD-UI-32 用户实看裁定「整体字偏小」定下的,不退;
///   `ring` `#737373`(egui 没有 focus ring 字段可落)、`chart_1..5`(图表色板在渲染侧
///   另钉,见 `render.rs` 的 K线色板)、`sidebar` 一族(我们没有 shadcn 意义的 sidebar)、
///   `destructive` `#FF6467`(错误语义色走 `semantic::ERROR`,金融语义不玩配色)。
pub fn visuals() -> Visuals {
    use shadcn::*;
    let r_card = CornerRadius::same(CORNER_CARD);
    let r_widget = CornerRadius::same(CORNER_WIDGET);
    let mut v = Visuals::dark();
    v.dark_mode = true;
    v.window_fill = BG1;
    v.panel_fill = BG1;
    v.extreme_bg_color = BG1; // 输入框底 = 页面底(shadcn 同值)
    v.faint_bg_color = BG2; // card
    v.hyperlink_color = ACCENT4;
    v.weak_text_color = Some(TEXT3);
    v.window_stroke = egui::Stroke::new(STROKE_WIDTH, BORDER);
    v.window_corner_radius = r_card;
    v.menu_corner_radius = r_card;
    // 不设(有意):text_edit_bg_color(缺省跟随 extreme_bg_color,同值设两遍是重复)、
    // code_bg_color(ui.monospace 只换字体不带背景)、warn/error_fg_color(语义色在渲染侧
    // 直绘)、slider/progress 相关(本 UI 0 处 slider、0 处进度条)。

    let stroke1 = egui::Stroke::new(STROKE_WIDTH, TEXT1);
    let border = egui::Stroke::new(STROKE_WIDTH, BORDER);
    // `WidgetVisuals` / `Selection` 不在 egui 的根 re-export 里(lib.rs:508 只出
    // Style / TextStyle / Visuals / Spacing)→ 走 `egui::style::` 全路径。
    v.widgets.noninteractive = egui::style::WidgetVisuals {
        bg_fill: BG1,
        weak_bg_fill: BG1,
        bg_stroke: border, // 分隔线 / 窗框 / 网格线 / 行线 = `border`
        corner_radius: r_widget,
        fg_stroke: stroke1,
        expansion: 0.0,
    };
    v.widgets.inactive = egui::style::WidgetVisuals {
        bg_fill: BG4,
        weak_bg_fill: BG4,
        bg_stroke: border, // 表头带底 / 未选中 tab 头底 / 按钮底 + 1px 描边
        corner_radius: r_widget,
        fg_stroke: stroke1,
        expansion: 0.0,
    };
    v.widgets.hovered = egui::style::WidgetVisuals {
        bg_fill: BG5,
        weak_bg_fill: BG5,
        bg_stroke: border,
        corner_radius: r_widget,
        fg_stroke: stroke1,
        expansion: 0.0,
    };
    v.widgets.active = egui::style::WidgetVisuals {
        bg_fill: BG6,
        weak_bg_fill: BG6,
        bg_stroke: border,
        corner_radius: r_widget,
        fg_stroke: stroke1,
        expansion: 0.0,
    };
    v.widgets.open = egui::style::WidgetVisuals {
        bg_fill: BG6,
        weak_bg_fill: BG6,
        bg_stroke: border,
        corner_radius: r_widget,
        fg_stroke: stroke1,
        expansion: 0.0,
    };
    v.selection = egui::style::Selection {
        bg_fill: ACCENT2,
        stroke: egui::Stroke::new(STROKE_WIDTH, TEXT1), // 「选中项文字色」,不是边框色
    };
    // Qt 的按钮 / 链接是手型光标;egui 缺省 None(浏览器口径)。shadcn 也是浏览器口径的
    // 手型光标(pointer-cursor: pointer),这一条与旧主题同值。
    v.interact_cursor = Some(egui::CursorIcon::PointingHand);
    v
}

/// 涨跌色唯一入口(毛病 3 的语义半边:数字成列之外,颜色也必须按语义走,
/// 不许各处自己 `if v > 0 {红} else {绿}` 现编)。
/// `v > 0` → 涨(红);`v < 0` → 跌(绿);`v == 0` → 正文色(不染色)。
pub fn rise_fall(v: f64) -> Color32 {
    if v > 0.0 {
        semantic::RISE
    } else if v < 0.0 {
        semantic::FALL
    } else {
        shadcn::TEXT1
    }
}