Skip to main content

ek_ui/
tokens.rs

1//! 设计令牌层(0.2.30,BD-UI-46;0.8.0 配色双档,BD-UI-61)。
2//!
3//! 为什么要有这一层:0.2.29 之前「颜色」散在两个地方 —— 主题函数里一份(五套
4//! `Visuals`),渲染代码里另一份(`from_rgb` 直绘 50 多处:多红 / 空绿 / 警告橙 /
5//! 错误深红 / 成功深绿 / 次级灰蓝 / 灰)。同一支语义色在不同表里各写各的,改一处
6//! 不会传到另一处;而且「涨跌色」这种**必须恒定**的东西没有唯一源。
7//!
8//! 0.2.30 起规矩写死:**`src/` 里除本模块,`from_rgb(` 出现次数必须为 0**(机械钉
9//! `u2_no_hardcoded_colors` 挡着)。所有色、字号、间距、控件尺寸、圆角从这里出。
10//!
11//! 0.8.0 起这一层多了一件事:**色板成对(深 / 浅两档),档由这里出,渲染侧只认访问器**。
12//!
13//! 裁定链(2026-10-22):用户「界面风格参考 egui-shadcn」→ 先把两个同名库都装下来实测
14//! 才动手:① crates.io 的 `egui-shadcn 0.5.0`(= FerrisMind/shadcn-rs)与用户先前给的
15//! `pjankiewicz/egui-shadcn`(0.1.0,不在 crates.io 上)不是同一个项目;② 两家的
16//! `Button` / tab 触发器 / `Select` 触发器都只把文字 `painter` 画出去,AccessKit 语义树
17//! 里查不到(探针实测:「钮A」AccessKit = 0、「委托E」「下拉D」= 0,对照原生 egui 按钮 = 1)
18//! —— 换控件 = 判据面整片失效。→ **裁定 = 只搬皮肤(色板 + 圆角 + 1px 描边),不引库**:
19//! 控件层、表格层(egui_table 0.7.0)、判据面结构一律不动。
20//!
21//! 主题档位的裁定链(改判要留痕,免得日后看不出为什么有两档):
22//! - 0.2.30 裁定 8(BD-UI-46):五套主题一键循环 → **减成一套 Qt深色**,菜单钮删除;
23//! - 0.4.1 四期(BD-UI-57):那套换成 shadcn Nova 深色,`Theme` 枚举与循环钮一起退役;
24//! - **0.8.0(BD-UI-61)改判**:用户实看判词「这个配色特别刺眼睛」+ 指定参考站
25//!   (`http://127.0.0.1:8080`,Strata)要「白色和黑色两款,黑色我们本来就有」→
26//!   深色档按参考站**重调**(A),新增浅色档,**菜单放一颗两档切换钮**(单点入口),
27//!   档存 ek-cfg 存储根 `ns="ui" key="theme"`,缺省 = 深色(B:现行为零变化)。
28//!   钉 `u1_menu_has_no_theme_button_and_nova_is_the_only_look` 随改判**改向**为
29//!   「恰有一颗钮 + 两档底色都真落到画面上」。
30//!
31//! 参考站是怎么配的(实抓 `web/tokens.css` / `web/components.css`,不是印象):
32//! 同名令牌两套值、换档整表换;底四支(`--st-bg` `#f9fafb` / `--st-surface` `#ffffff` /
33//! `--st-surface-2` `#f7f7f7` / `--st-line` `#dee5ee`)、文字三支(`ink` `#221e1f` /
34//! `ink-soft` `#303030` / `ink-muted` `#6f6f6f`);表头 = `th{text-align:left;
35//! color:var(--st-ink-muted);border-bottom:1px solid var(--st-line)}`;
36//! 语义色是 base + tint + text 三合一的一族。**不抄的也记死**:Outfit 字体(我们的字号
37//! 阶梯 22/16/14 是 BD-UI-32 用户实看裁定)、box-shadow(egui 无阴影层,靠 1px 描边分层)、
38//! badge tint 底(我们没有徽标面)、`text-transform:uppercase`(中文表头不适用)、
39//! 系统配色探测(eframe/winit 0.33 无窗口级 API,缺省就是深色)。
40//!
41//! 色值源:旧深档 = `ColorPalette::shadcn_dark(Neutral)` 实测 26 支(DESIGN BD-UI-57 对照表);
42//! 0.8.0 新深档/浅档 = 参考站色板逐支 + 本机 WCAG 相对亮度公式实算对比度定值(判据 `u10`)。
43//!
44//! 旧值逐支留在下面每条令牌的注释里(本仓纪律):CHANGELOG 0.4.1 四期有 Nova 整张对照表,
45//! CHANGELOG 0.8.0 有本轮双档整张对照表,可回溯。
46
47use egui::{Color32, CornerRadius, FontId, RichText, Style, TextStyle, Vec2, Visuals};
48
49/// 界面档位(0.8.0,BD-UI-61)。**只有两档**,缺省 = 深色(用户裁定 B:现行为零变化)。
50///
51/// 持久化口径:落 ek-cfg 存储根 `ns="ui" key="theme"`,串值 = `as_str()`;
52/// **只有手点过切换钮才落盘**(照参考站「只有点击才存 localStorage」),
53/// 没落盘 = 缺省深色。脏串一律回落缺省(`from_str`),不给"未知档"留活口。
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum Theme {
56    /// 深色(0.8.0 值 = 参考站深档配对;0.2.30–0.7.0 = shadcn Nova 深色)。
57    #[default]
58    Dark,
59    /// 浅色(0.8.0 新增)。
60    Light,
61}
62
63impl Theme {
64    /// 落盘串值。
65    pub const fn as_str(self) -> &'static str {
66        match self {
67            Self::Dark => "dark",
68            Self::Light => "light",
69        }
70    }
71
72    /// 读盘:脏串 / 空串 → 缺省深色。
73    ///
74    /// 有意**不**实现 `std::str::FromStr`(clippy 会建议):读盘口径是「脏值 = 缺省档」的
75    /// 宽容映射,不产出 `Err` —— 落盘文件是用户手边可编辑的配置,给不出「解析失败」这回事
76    /// (判据 u12 钉的就是这层宽容:别名进、脏值出 Dark)。
77    #[allow(clippy::should_implement_trait)]
78    pub fn from_str(s: &str) -> Self {
79        match s.trim().to_ascii_lowercase().as_str() {
80            "light" | "浅色" => Self::Light,
81            _ => Self::Dark,
82        }
83    }
84
85    /// 切换钮要的「另一档」。
86    pub const fn flipped(self) -> Self {
87        match self {
88            Self::Dark => Self::Light,
89            Self::Light => Self::Dark,
90        }
91    }
92
93    /// 跨线程存放形态(`UiShared.theme` 是 `AtomicU8`,`0 = Dark`)。
94    pub const fn as_u8(self) -> u8 {
95        match self {
96            Self::Dark => 0,
97            Self::Light => 1,
98        }
99    }
100
101    /// 读跨线程存放形态;脏值回落缺省深色(不给「未知档」留活口)。
102    pub const fn from_u8(v: u8) -> Self {
103        match v {
104            1 => Self::Light,
105            _ => Self::Dark,
106        }
107    }
108
109    /// 这一档的色板。
110    pub const fn palette(self) -> Palette {
111        match self {
112            Self::Dark => PALETTE_DARK,
113            Self::Light => PALETTE_LIGHT,
114        }
115    }
116}
117
118/// 一整套底/文字/描边令牌(两支**同名字段、同支数** —— 判据 `u9` 逐支配对钉挡「浅档漏一支」,
119/// 这是双档改造最高频的漏法)。
120///
121/// 字段名 = 角色名,与 `shadcn::` 里的旧令牌名一一对应,渲染侧不需要学两套名字。
122#[derive(Debug, Clone, Copy, PartialEq)]
123pub struct Palette {
124    /// 窗底 / 面板底 / 输入框底。
125    pub bg1: Color32,
126    /// 次级底(`faint_bg_color`,卡片)。
127    pub bg2: Color32,
128    /// 表头带底 / 未选中 tab 底 / 控件底(`widgets.inactive`)。
129    pub bg4: Color32,
130    /// hover 底(`widgets.hovered`)。
131    pub bg5: Color32,
132    /// pressed / active / checked(`widgets.active`)。
133    pub bg6: Color32,
134    /// 选中行底(`selection.bg_fill`)。
135    pub accent2: Color32,
136    /// 强调:选中 tab 下划线、链接、分隔条拖拽/悬停高亮。
137    pub accent4: Color32,
138    /// 一切描边(1px)。
139    pub border: Color32,
140    /// **行线专用**描边(0.8.2 落点对表:参考站 `th{border-bottom:var(--st-line)}` 与
141    /// `td{border-bottom:var(--st-line-soft)}` 是**两支** —— 表头那根重、行线那根轻;
142    /// 我们旧口径一根 BORDER 通吃,行线就比参考站重一档)。
143    pub border_soft: Color32,
144    /// 正文 / 标题。
145    pub text1: Color32,
146    /// 表体文字(0.8.0 新增,用户裁定 C)。
147    pub text2: Color32,
148    /// 次级文字 + 表头文字。
149    pub text3: Color32,
150}
151
152/// 深档 = 参考站 Strata 深档配对(用户裁定 A「深色档按参考站重调」)。
153/// 逐支旧值(Nova)写在 `shadcn::` 各令牌的注释里。
154pub const PALETTE_DARK: Palette = Palette {
155    bg1: shadcn::BG1,
156    bg2: shadcn::BG2,
157    bg4: shadcn::BG4,
158    bg5: shadcn::BG5,
159    bg6: shadcn::BG6,
160    accent2: shadcn::ACCENT2,
161    accent4: shadcn::ACCENT4,
162    border: shadcn::BORDER,
163    border_soft: shadcn::BORDER_SOFT,
164    text1: shadcn::TEXT1,
165    text2: shadcn::TEXT2,
166    text3: shadcn::TEXT3,
167};
168
169/// 浅档 = 参考站 Strata 浅档配对。**逐支实算过对比度**(判据 `u10`,本机按 WCAG 公式算的):
170/// 正文 `#221e1f` / 底 `#f9fafb` = **15.78:1**;表体 `#303030` = 12.63;次级与表头
171/// `#5b6169` = 5.98(对表头带底 `#e9edf2` = **5.32**,达标线 4.5)。
172///
173/// 两处**有意偏离参考站**(不是漏抄,是照搬到 egui 上会出事的):
174/// 1. 描边取实色 `#d5dde8`(参考站浅档 `#dee5ee`)—— 参考站的 `th` 底是白卡,我们的表头带
175///    有底,同名义色在带底上不够;且旧「白 10% alpha 描边」在白面板上**等于没有描边**。
176/// 2. 控件底/表头带 `#e9edf2`(参考站 `--st-surface-2` `#f7f7f7`)—— `#f7f7f7` 对
177///    `#f9fafb` 面板底只有 1.05:1,滚动条把手与表头带**看不见**;取 `#e9edf2` 到 1.13:1,
178///    与 1px 实色描边共同承担分层(参考站就是"弱底 + 描边"两件事一起干)。
179pub const PALETTE_LIGHT: Palette = Palette {
180    bg1: Color32::from_rgb(0xF9, 0xFA, 0xFB),
181    bg2: Color32::from_rgb(0xFF, 0xFF, 0xFF),
182    bg4: Color32::from_rgb(0xE9, 0xED, 0xF2),
183    bg5: Color32::from_rgb(0xDD, 0xE4, 0xEC),
184    bg6: Color32::from_rgb(0xD3, 0xDB, 0xE6),
185    accent2: Color32::from_rgb(0xE8, 0xED, 0xF4),
186    accent4: Color32::from_rgb(0x40, 0x5D, 0xE6),
187    border: Color32::from_rgb(0xD5, 0xDD, 0xE8),
188    border_soft: Color32::from_rgb(0xEE, 0xEE, 0xEE),
189    text1: Color32::from_rgb(0x22, 0x1E, 0x1F),
190    text2: Color32::from_rgb(0x30, 0x30, 0x30),
191    text3: Color32::from_rgb(0x5B, 0x61, 0x69),
192};
193
194/// 语义色族(同名字段成对,`u9` 钉守着)。**hue 恒定,浅色档只降明度**(红线「金融语义
195/// 不玩配色」;判据 `u11` 钉:两档里 RISE 必红、FALL 必绿、WARN 必橙、OK 必绿)。
196#[derive(Debug, Clone, Copy, PartialEq)]
197pub struct Semantic {
198    /// 涨 / 多头 / 买 —— 红。
199    pub rise: Color32,
200    /// 跌 / 空头 / 卖 —— 绿。
201    pub fall: Color32,
202    /// 警告 / 待办提示 —— 橙。
203    pub warn: Color32,
204    /// 错误 / 失败 / 隔离 —— 深红(比涨沉一档,免得把「跌」和「错」混成同一支)。
205    pub error: Color32,
206    /// 成功 / 已启动 / 已连接 —— 绿(比跌沉一档)。
207    pub ok: Color32,
208    /// 状态类信息(总线状态、来源、中性提示)。
209    pub info: Color32,
210    /// 次级 / 弱化 / 口径说明。
211    pub muted: Color32,
212}
213
214/// 深档语义色:除 `OK` 一支提亮(自审 R6 的签字破例)外**逐支零动**。
215///
216/// `OK` 为什么要破例:旧值 `#2E7D32` 对**旧纯黑底 `#0A0A0A` 只有 3.24:1**,对新的
217/// `#0e1113` 更低 —— 这是现档就有的旧账,不是本轮引入。`u10` 阈值 4.5:1 要过,
218/// 只能把明度提一档 → `#4a9e52`(对新底 5.69 / 对带底 4.82 / 对选中底 4.64)。
219/// hue 与「绿 = 成功」的语义不变,动的只有明度。
220pub const SEMANTIC_DARK: Semantic = Semantic {
221    rise: semantic::RISE,
222    fall: semantic::FALL,
223    warn: semantic::WARN,
224    error: semantic::ERROR,
225    ok: semantic::OK,
226    info: semantic::INFO,
227    muted: semantic::MUTED,
228};
229
230/// 浅档语义色:hue 与深档同(红/绿/橙/深红/绿/蓝/灰),只把明度压下来,
231/// 保证浅底上 ≥4.5:1(实算:涨 5.42 / 跌 5.20 / 警 5.67 / 错 6.63 / 成 7.40 / 状态 5.33)。
232pub const SEMANTIC_LIGHT: Semantic = Semantic {
233    rise: Color32::from_rgb(0xC0, 0x30, 0x30),
234    fall: Color32::from_rgb(0x1F, 0x7A, 0x1F),
235    warn: Color32::from_rgb(0x8A, 0x5A, 0x00),
236    error: Color32::from_rgb(0xB0, 0x1A, 0x2E),
237    ok: Color32::from_rgb(0x16, 0x65, 0x34),
238    info: Color32::from_rgb(0x06, 0x68, 0xC2),
239    muted: Color32::from_rgb(0x6F, 0x6F, 0x6F),
240};
241
242/// 告警(toast)三档皮肤 = 参考站语义色「base + tint + text」三合一那族的落点。
243///
244/// 为什么浅档必须换字色(不是"调一点点"):现值 WARNING `#FAAD14` 对浅底白卡
245/// **只有 1.9:1**,在浅档上等于糊字。浅档取参考站的 tint 底 + text 字
246/// (实算:警 5.51 / 状态 4.99 / 故障 6.07)。
247#[derive(Debug, Clone, Copy, PartialEq)]
248pub struct AlertSkin {
249    /// INFO 字色。
250    pub info: Color32,
251    /// WARNING 字色。
252    pub warning: Color32,
253    /// ERROR 字色。
254    pub error: Color32,
255    /// INFO 软底(tint)。
256    pub info_tint: Color32,
257    /// WARNING 软底。
258    pub warning_tint: Color32,
259    /// ERROR 软底。
260    pub error_tint: Color32,
261    /// INFO 描边。
262    pub info_border: Color32,
263    /// WARNING 描边。
264    pub warning_border: Color32,
265    /// ERROR 描边。
266    pub error_border: Color32,
267}
268
269/// 深档 = Python `AlertToast._LEVEL_COLORS` 原值(BD-UI-50 钉着的三支);
270/// 软底/描边在深档沿现口径(底 = `bg2` 卡底,描边 = 级别色)。
271pub const ALERT_DARK: AlertSkin = AlertSkin {
272    info: alert::INFO,
273    warning: alert::WARNING,
274    error: alert::ERROR,
275    info_tint: shadcn::BG2,
276    warning_tint: shadcn::BG2,
277    error_tint: shadcn::BG2,
278    info_border: alert::INFO,
279    warning_border: alert::WARNING,
280    error_border: alert::ERROR,
281};
282
283/// 浅档 = 参考站 tint 底 + text 字(见上条注释的实算数)。
284pub const ALERT_LIGHT: AlertSkin = AlertSkin {
285    info: Color32::from_rgb(0x06, 0x68, 0xC2),
286    warning: Color32::from_rgb(0x8A, 0x5A, 0x00),
287    error: Color32::from_rgb(0xB0, 0x1A, 0x2E),
288    info_tint: Color32::from_rgb(0xE8, 0xF4, 0xFF),
289    warning_tint: Color32::from_rgb(0xFF, 0xF6, 0xE0),
290    error_tint: Color32::from_rgb(0xFD, 0xEC, 0xEE),
291    info_border: Color32::from_rgb(0xB9, 0xDC, 0xF7),
292    warning_border: Color32::from_rgb(0xF0, 0xD9, 0xA0),
293    error_border: Color32::from_rgb(0xF5, 0xB8, 0xC2),
294};
295
296/// `Theme` 在 `Context::data` 里的键(全仓唯一;`egui::Id::new` 不是 const fn,故做成函数)。
297fn theme_key() -> egui::Id {
298    egui::Id::new("ek-ui.theme")
299}
300
301/// 当前档(从 `Context` 读,缺省 = 深色)。
302///
303/// 为什么档住在 `Context::data` 而不是把 `Theme` 穿过三十个函数签名:渲染侧取色点
304/// 全在拿得到 `ui.ctx()` 的闭包里,访问器一律 `tokens::rise(ui.ctx())`;
305/// kittest 夹具跑的就是 `render_body` → 夹具改一行 `shared.set_theme(..)` 就能整帧换档。
306pub fn theme_of(ctx: &egui::Context) -> Theme {
307    ctx.data(|d| d.get_temp::<Theme>(theme_key()))
308        .unwrap_or_default()
309}
310
311/// 装档(由 `apply_ui_style` 每帧调用;`Theme` 变了下一帧生效,点击自带 repaint)。
312pub fn set_theme(ctx: &egui::Context, theme: Theme) {
313    ctx.data_mut(|d| d.insert_temp(theme_key(), theme));
314}
315
316/// 当前档的色板。
317pub fn palette(ctx: &egui::Context) -> Palette {
318    theme_of(ctx).palette()
319}
320
321/// 当前档的语义色族。
322pub fn semantic_of(ctx: &egui::Context) -> Semantic {
323    semantic_of_theme(theme_of(ctx))
324}
325
326/// 按档取语义色族(渲染侧拿不到 ctx 的地方用这一支,例如构造期算好存着)。
327pub const fn semantic_of_theme(theme: Theme) -> Semantic {
328    match theme {
329        Theme::Dark => SEMANTIC_DARK,
330        Theme::Light => SEMANTIC_LIGHT,
331    }
332}
333
334/// 当前档的告警皮肤。
335pub const fn alert_of_theme(theme: Theme) -> AlertSkin {
336    match theme {
337        Theme::Dark => ALERT_DARK,
338        Theme::Light => ALERT_LIGHT,
339    }
340}
341
342// 渲染侧取色访问器(7 支语义 + 1 支涨跌)。**新代码不许直用 `semantic::` 常量** ——
343// 直用 = 把深档值硬钉进渲染路径,浅色档下那一处就不跟档(判据 s90 抓的就是这一类)。
344/// 涨 / 买 —— 当前档红。
345pub fn rise(ctx: &egui::Context) -> Color32 {
346    semantic_of(ctx).rise
347}
348/// 跌 / 卖 —— 当前档绿。
349pub fn fall(ctx: &egui::Context) -> Color32 {
350    semantic_of(ctx).fall
351}
352/// 警告橙 —— 当前档。
353pub fn warn(ctx: &egui::Context) -> Color32 {
354    semantic_of(ctx).warn
355}
356/// 错误深红 —— 当前档。
357pub fn error(ctx: &egui::Context) -> Color32 {
358    semantic_of(ctx).error
359}
360/// 成功绿 —— 当前档。
361pub fn ok(ctx: &egui::Context) -> Color32 {
362    semantic_of(ctx).ok
363}
364/// 状态类信息色 —— 当前档。
365pub fn info(ctx: &egui::Context) -> Color32 {
366    semantic_of(ctx).info
367}
368/// 次级/弱化灰 —— 当前档。
369pub fn muted(ctx: &egui::Context) -> Color32 {
370    semantic_of(ctx).muted
371}
372
373/// shadcn/ui Nova 深色色板 → **0.8.0 的双档「深档」**。
374/// **全仓唯一允许写 `from_rgb(` 的地方**(判据 `u2`,浅档值就在上面 `PALETTE_LIGHT` 里)。
375/// 令牌名沿用角色名(BG* = 底档、TEXT* = 文字档、ACCENT* = 强调档)—— 换的是值不是
376/// 名字,渲染侧的取色口径不变;`PALETTE_DARK` 就是这一组常量的成对拷贝。
377///
378/// 0.8.0 换值的根由(裁定 A):旧值是 Nova 的**纯中性** —— `background` `#0A0A0A` 纯黑 +
379/// `foreground` `#FAFAFA` 近纯白 = **18.97:1**,用户实看判词「刺眼」。参考站深档不是纯黑:
380/// `#0e1113` 底(带蓝灰)+ `#eef1f3` 字 = **16.70:1**,层次靠 `surface` 一档一档抬。
381/// **本轮治的就是这七支**(底 5 + 描边 + 文字 3),语义色除 `OK` 提亮外零动。
382pub mod shadcn {
383    use super::*;
384    /// 窗底 / 容器底 / 输入框底。**0.8.0 `#0e1113`(参考站 `--st-bg`)**;旧 Nova `#0A0A0A`、
385    /// 更早 qdark Gray.B10 `#19232D`。
386    pub const BG1: Color32 = Color32::from_rgb(0x0E, 0x11, 0x13);
387    /// 比背景亮一档的次级底(= `faint_bg_color`)。**0.8.0 `#161a1d`(参考站 `--st-surface`)**;
388    /// 旧 Nova `#171717`、旧 Gray.B20 `#293544`。
389    pub const BG2: Color32 = Color32::from_rgb(0x16, 0x1A, 0x1D);
390    /// 表头带底、未选中 tab 头底、控件底(= `widgets.inactive.bg_fill`,也是滚动条把手底)。
391    /// **0.8.0 `#1d2226`(参考站 `--st-surface-2`)**;旧 Nova `#262626`、旧 Gray.B40 `#455364`。
392    pub const BG4: Color32 = Color32::from_rgb(0x1D, 0x22, 0x26);
393    /// hover 底、选中 tab 底。**0.8.0 `#2a3036`(参考站 `--st-line`)**;旧 Nova `#404040`、旧 Gray.B50 `#54687A`。
394    pub const BG5: Color32 = Color32::from_rgb(0x2A, 0x30, 0x36);
395    /// pressed / checked。**0.8.0 `#343b42`(同一中性阶取一档)**;旧 Nova 合成色 `#525252`、旧 Gray.B60 `#60798B`。
396    pub const BG6: Color32 = Color32::from_rgb(0x34, 0x3B, 0x42);
397    /// 正文 / 标题 / 选中项文字。**0.8.0 `#eef1f3`(参考站 `--st-ink`,对 `BG1` 16.70:1)**;
398    /// 旧 Nova `#FAFAFA`(对旧底 18.97:1,就是「刺」的来源)、旧 Gray.B130 `#DFE1E2`。
399    pub const TEXT1: Color32 = Color32::from_rgb(0xEE, 0xF1, 0xF3);
400    /// **表体文字(0.8.0 新增,用户裁定 C)** = 参考站 `--st-ink-soft` `#c7cdd3`(11.82:1)。
401    /// 为什么要第三层:旧口径只有 `TEXT1`/`TEXT3`,标题与表体同色 = 层级塌一档;
402    /// 参考站是 `ink`(标题)/ `ink-soft`(表体)/ `ink-muted`(次级)三层。浅档对应 `#303030`。
403    pub const TEXT2: Color32 = Color32::from_rgb(0xC7, 0xCD, 0xD3);
404    /// 次级文本 + **表头文字**(0.8.0 起表头走这一支,参考站 `th{color:ink-muted}` 口径)。
405    /// **0.8.0 `#8e979f`(参考站 `--st-ink-muted`,对底 6.39 / 对带底 5.41)**;旧 Nova `#A1A1A1`、旧 Gray.B90 `#ACB1B6`。
406    pub const TEXT3: Color32 = Color32::from_rgb(0x8E, 0x97, 0x9F);
407    /// 选中行 / 选中文本底。**0.8.0 `#343b42`(= `BG6`,与 pressed 同档,旧口径照旧)**;
408    /// 旧 Nova 合成色 `#525252`、旧 Blue.B40 `#346792`。
409    ///
410    /// 为什么不是执行途中一度采用的 `#20252a`(自审 R5 → 落地时被判据 `u1` 挡回,记档 R11):
411    /// `#20252a` 让选中行的涨/跌字到 4.00/4.45,但它**比 hover 底 `BG5` `#2a3036` 更暗** →
412    /// 「选中 = 比 hover 再亮一档」这条 BD-UI-57 的层级纪律被反着写(选中行比悬停行更不显眼),
413    /// 判据 `u1_visuals_match_shadcn_nova_palette` 的底色阶梯钉(`hover < 选中`)当场红。
414    /// **判据不放宽,改色板**:选中底取 hover 再上一档 = `BG6`,配套把选中行上会出现的
415    /// 涨/跌/状态语义色按「明度跟档」提一档(见 `semantic` 的 R11 注释)→ 选中底上
416    /// 涨 4.06 / 跌 4.56 / 警 4.16 / 成 4.02 / 文 10.00,层级与对比两头都立。
417    /// 层级口径在两档同向:深档选中底更亮、浅档选中底更白(`#e8edf4` vs hover `#dde4ec`)。
418    pub const ACCENT2: Color32 = Color32::from_rgb(0x34, 0x3B, 0x42);
419    /// `primary` `#E5E5E5` —— 选中 tab 的 3px 下划线、链接色、分隔条拖拽/悬停高亮线。
420    /// **0.8.0 深档不动(用户裁定 E)**;浅档对应值 = 参考站 `--st-focus` `#405de6`。
421    /// 旧 Blue.B70 `#259AE9`。
422    pub const ACCENT4: Color32 = Color32::from_rgb(0xE5, 0xE5, 0xE5);
423    /// **一切描边**:窗框、分隔线、表头带下边线、网格线、行线、控件边框。
424    /// **0.8.0 改实色 `#2a3036`(参考站 `--st-line`)**;旧口径 = 白 10% alpha
425    /// (`oklch(1 0 0 / 10%)` → egui 预乘后 `#1A1A1A_1A`),Nova 之前是「描边 = BG4 同色」。
426    ///
427    /// 为什么脱掉 alpha(两处,浅档更要命):① alpha 白描边落在 `BG1`/`BG4`/`ACCENT2`
428    /// 三种底上出来的灰不一样,同一根线在表头带与表体上不齐;② 浅档里白 10% 描边在
429    /// `#f9fafb` 面板底上**等于没有描边**(参考站浅档本来就是实色 `#dee5ee`)。
430    /// 「描边 = 一支」的纪律不变,变的只是它不再是 alpha 构造(判据 `u2` 的
431    /// `from_rgba_unmultiplied` 那一支随之下岗,注释块同步改写,不留注释旧账)。
432    pub const BORDER: Color32 = Color32::from_rgb(0x2A, 0x30, 0x36);
433    /// **行线专用**描边 = 参考站 `--st-line-soft`(深 `#22272c` / 浅 `#eeeeee`)。
434    /// 参考站把「表头那根线」与「行线」分成两支:`th{border-bottom:var(--st-line)}`、
435    /// `td{border-bottom:var(--st-line-soft)}`。0.8.0 通吃一支 `BORDER` → 行线比参考站重
436    /// 一档(0.8.2 用户复看看出「风格不对」的落点之一)。
437    pub const BORDER_SOFT: Color32 = Color32::from_rgb(0x22, 0x27, 0x2C);
438}
439
440/// 告警三级色(0.3.2 Wave E,BD-UI-50)—— 值就是 Python `AlertToast._LEVEL_COLORS`
441/// (alert_toast.py:45-49)。**0.8.0 起这一组 = 深档值**(浅档对应值在 `ALERT_LIGHT`),
442/// 常量留着是给深档锚点与判据用的(`toast.rs` 的钉断言的就是「深档 = Python 原值一字不改」)。
443pub mod alert {
444    use egui::Color32;
445
446    /// Python INFO `#1890FF`。
447    pub const INFO: Color32 = Color32::from_rgb(0x18, 0x90, 0xFF);
448    /// Python WARNING `#FAAD14`。
449    pub const WARNING: Color32 = Color32::from_rgb(0xFA, 0xAD, 0x14);
450    /// Python ERROR `#FF4D4F`。
451    pub const ERROR: Color32 = Color32::from_rgb(0xFF, 0x4D, 0x4F);
452}
453
454/// 语义色:**跨界面恒定,不随主题漂移**(金融语义不玩配色)—— 0.8.0 的口径是
455/// **「hue 恒定、明度随档」**(判据 `u11` 钉着:两档里 RISE 必红、FALL 必绿、WARN 必橙)。
456/// 这一组常量 = **深档值**;渲染侧取色走 `tokens::rise(ctx)` 等访问器(浅档才会跟档)。
457pub mod semantic {
458    use super::*;
459    /// 涨 / 多头 / 买 —— 红。**0.8.0 明度提一档 `#f07575`**(旧 `#E05050`;R11:
460    /// 选中底取 `BG6` `#343b42` 后,旧红在选中行上只有 2.94:1 → 提一档到 4.06,
461    /// 页面底上 6.77。hue 不动:判据 `u11` 钉「RISE 必红」)。
462    pub const RISE: Color32 = Color32::from_rgb(0xF0, 0x75, 0x75);
463    /// 跌 / 空头 / 卖 —— 绿。**0.8.0 明度提一档 `#42bb46`**(旧 `#2E9E2E`;R11 同上:
464    /// 选中行上 4.56 / 页面底 7.60)。
465    pub const FALL: Color32 = Color32::from_rgb(0x42, 0xBB, 0x46);
466    /// 警告 / 待办 / 「先去做某事」提示 —— 橙(0.8.0 不动:选中行上 4.16 已达标)。
467    pub const WARN: Color32 = Color32::from_rgb(0xE0, 0x88, 0x10);
468    /// 错误 / 失败 / 隔离 —— 深红(比 RISE 沉一档,免得把「跌」和「错」混成同一支)。
469    /// **0.8.0 明度提一档 `#dc6060`**(旧 `#C03030`:页面底 3.34 不达标 → 5.31;
470    /// 选中行上 3.18,`u10` 给 ERROR 的底线是 ≥3.0,它在页面底/卡片底上 4.6/4.9)。
471    pub const ERROR: Color32 = Color32::from_rgb(0xDC, 0x60, 0x60);
472    /// 成功 / 已启动 / 已连接 —— 绿。**0.8.0 提亮一档 `#54ac5c`**(自审 R6 签字破例的
473    /// 落地值:旧 `#2E7D32` 对页面底只有 3.24:1 本就不达 AA;R11 后选中底变 `BG6`,
474    /// 中途的 `#4a9e52` 在选中行上只 3.41 → 再提一档,选中行 4.02 / 页面底 6.71)。
475    pub const OK: Color32 = Color32::from_rgb(0x54, 0xAC, 0x5C);
476    /// 状态类信息(总线状态、来源、中性提示)—— 淡蓝灰(0.8.0 不动:选中行 4.72)。
477    pub const INFO: Color32 = Color32::from_rgb(0x9A, 0xA8, 0xC0);
478    /// 次级 / 弱化 / 口径说明 —— 中性灰。**0.8.0 提一档 `#949494`**(旧 `#888888`:
479    /// 页面底 5.35 → 6.25;选中行上 3.74,这一支的用途就是「弱化/没事」,`u10` 给它 ≥3.0)。
480    pub const MUTED: Color32 = Color32::from_rgb(0x94, 0x94, 0x94);
481}
482
483/// 字号阶梯(BD-UI-32 R1 用户实看裁定「整体字偏小」,对照 Python 版;
484/// egui 缺省 13 / 12 / 18 / 12)。三档层级是毛病 7「无视觉层级」的解法:
485/// 标题 / 正文 / 次级必须**肉眼可分**,不是只换个字重。
486/// **0.8.0 不动**(双档改造不碰字号,参考站的 Outfit 字体不抄)。
487pub const FONT_HEADING: f32 = 22.0;
488pub const FONT_BODY: f32 = 16.0;
489pub const FONT_SMALL: f32 = 14.0;
490pub const FONT_MONO: f32 = 15.0;
491
492/// **格内左右内边距(0.8.0 对齐统一的锚,判据 s91)**:体表格每一格的 `Frame::inner_margin`
493/// 与表头文字起笔**必须吃同一个常量**(旧口径两处各写 6,表头比表体靠左 6px = 用户报的
494/// 「表头和表的内容对齐方式不一致」)。
495pub const CELL_INSET_X: f32 = 6.0;
496
497/// 控件与间距规格(shadcn 口径:描边 1px;圆角两档 = `--radius` 10px 给卡片 / 窗 / 菜单,
498/// 控件取 `calc(var(--radius) - 2px)` = 8px —— 库内 `DEFAULT_RADIUS` 刻度实测
499/// `r1 4 / r2 6 / r3 8 / r4 10 / r5 12 / r6 16`,`ControlSize::Sm/Md/Lg` 的控件圆角
500/// 正是 6 / 8 / 10。旧 qss 口径是一律 4px,描边 1px 不变)。
501pub const CORNER_CARD: u8 = 10;
502pub const CORNER_WIDGET: u8 = 8;
503pub const STROKE_WIDTH: f32 = 1.0;
504/// 行内水平间距 / 垂直行间距(egui 缺省 8 / 4;垂直 4 在 16px 字号下太挤)。
505pub const SPACING_ITEM_X: f32 = 8.0;
506pub const SPACING_ITEM_Y: f32 = 6.0;
507/// 控件目标高度(16px 字号 + qss 的 2px padding 上下 + 描边)。
508pub const INTERACT_HEIGHT: f32 = 26.0;
509/// 滚动条粗细(0.4.1 七期 BD-UI-60:10 → 5,用户裁定「尽可能细一点」)。
510pub const SCROLL_BAR_WIDTH: f32 = 5.0;
511/// 分组内边距(`Frame::group` 用)。
512pub const FRAME_INNER: f32 = 8.0;
513/// 组与组之间、区块之间的呼吸位(`UiExt::space_group` 用)。
514pub const GROUP_GAP: f32 = 12.0;
515
516/// 布局尺寸(0.4.1 BD-UI-54 回归原生容器:比例常量退役,回到面板的**像素**缺省值,
517/// 与 0.3.x 同值 —— dock 时代那对比例常量 `MARKET_PANE_FRACTION`/`ORDER_PANE_FRACTION`
518/// 随 egui_dock 一起退役)。
519///
520/// - 行情带缺省高 `240` = Python `v_splitter.setSizes([240, 560])` 的上带(面板可拖,
521///   30:70 只是初值);
522/// - 下单侧缺省宽 `300` = Python `TradingWidget` 的 `setFixedWidth(300)`
523///   (`widget.py:1748`,定死不是建议)。
524pub const MARKET_BAND_HEIGHT: f32 = 240.0;
525pub const ORDER_SIDE_WIDTH: f32 = 300.0;
526
527/// 数字格的唯一写法(0.8.0 对齐统一,判据 `t3`)。
528///
529/// 口径:**全左对齐 + 等宽数字**。旧口径数字列靠右对齐成列(六张表的 `RIGHT_COLS` 35 个
530/// 格位),用户裁定「全部用左对齐」→ 右对齐那半退役,`tabular-nums` 那半(等宽)留下:
531/// `format::price` 剥尾零(`101.0→"101"`、`99.7`),宽度天生不等,等宽只保「字符宽度一致、
532/// 位数可数」,**保不了「小数点同一竖线」—— 这是全左对齐明码标出的代价**(方案 §5 R7)。
533/// 日志窗早就是这个口径(`ui.monospace`)。
534pub fn num_text(s: impl Into<String>) -> RichText {
535    RichText::new(s).monospace()
536}
537
538/// 字号阶梯 + 间距 + 控件尺寸(与档无关的部分),一次装好。
539/// 真实 App、独立子窗(immediate 共享同一 Context)、kittest 三处同源。
540pub fn apply(style: &mut Style) {
541    style
542        .text_styles
543        .insert(TextStyle::Body, FontId::proportional(FONT_BODY));
544    style
545        .text_styles
546        .insert(TextStyle::Button, FontId::proportional(FONT_BODY));
547    style
548        .text_styles
549        .insert(TextStyle::Small, FontId::proportional(FONT_SMALL));
550    style
551        .text_styles
552        .insert(TextStyle::Heading, FontId::proportional(FONT_HEADING));
553    style
554        .text_styles
555        .insert(TextStyle::Monospace, FontId::monospace(FONT_MONO));
556    style.spacing.item_spacing = Vec2::new(SPACING_ITEM_X, SPACING_ITEM_Y);
557    style.spacing.interact_size = Vec2::new(40.0, INTERACT_HEIGHT);
558    // 滚动条形态(0.4.1 七期 BD-UI-60,用户裁定「滚动条太粗了,尽可能细一点,然后暗一些」):
559    // - 细:`bar_width` 10 → 5(旧值 10 是 0.2.29 定的,实测横向滚动条轨道 10 px 高)。
560    // - 暗:`foreground_color` 从 true 改 false。true 时把手取 `visuals.fg_stroke.color`
561    //   = TEXT1(全调色板最亮的一支,scroll_area.rs:1421-1425);false 时取
562    //   `widgets.inactive.bg_fill` = BG4,hover → BG5、拖 → BG6(递进反馈仍在)。
563    //   0.8.0 双档:浅档 BG4 = `#e9edf2` 对浅面板底 1.13:1 才**看得见**(参考站的
564    //   `#f7f7f7` 在白面板上等于看不见 → 有意偏离,见 `PALETTE_LIGHT` 注释)。
565    // - 占位口径不变(`floating = false`):Qt 的 `QScrollBar` 是占位的,不覆盖内容。
566    style.spacing.scroll = egui::style::ScrollStyle {
567        bar_width: SCROLL_BAR_WIDTH,
568        bar_inner_margin: 2.0,
569        ..egui::style::ScrollStyle::solid()
570    };
571}
572
573/// 当前档的视觉(渲染侧与夹具的唯一入口)。
574pub fn visuals_for(theme: Theme) -> Visuals {
575    visuals_of(theme.palette(), theme)
576}
577
578/// 按档装视觉(0.8.0 双档;旧签名 `visuals()` = 深档,给缺省口径留一个不动的入口)。
579pub fn visuals() -> Visuals {
580    visuals_of(PALETTE_DARK, Theme::Dark)
581}
582
583/// 色板 → egui `Visuals`。映射口径逐条对过 shadcn 与参考站的 token 用法,不是凭印象:
584/// - 页面 / 面板 / 输入框底 = `bg1`(两家都是所有容器同底,靠 1px 描边区分,所以
585///   `window_fill` 与 `panel_fill` 同值 —— 与旧 qdark 同构,不是偷懒)。`faint_bg_color` = `bg2`。
586/// - 控件底 = `bg4` → hover `bg5` → pressed `bg6`(pressed 是同一中性阶合成的下一档)。
587///   描边一律 `border`:旧口径「`bg_stroke` 与 `bg_fill` 同色 = 视觉无边」随 BG4 拆分一起退役
588///   —— 控件是**带 1px 描边**的。
589/// - expansion 一律 0.0:hover 只换色不涨块,egui 缺省 hover/active 涨 1px。
590/// - fg_stroke 宽度一律 1.0:hover/pressed 不加重文字,egui 缺省 1.5 / 2.0。
591/// - 选中行与选中文本 = `accent2`,比 hover 再亮一档;选中 tab 的「未选中底 / 选中底 +
592///   `accent4` 下划线」egui `Visuals` 里没有对应字段 → 由渲染侧 `tab_label` 直绘
593///   (取色从这份 visuals 派生;判据 s67/v5)。
594/// - `selection.stroke.color` 会被 egui 当**选中项文字色**用(style.rs:374)→ 取 `text1`。
595/// - **不搬的东西(记死,免得日后被当成漏)**:字号阶梯 22/16/14 不退(BD-UI-32 裁定);
596///   `ring`(egui 无 focus ring 字段)、`chart_1..5`(图表色板在渲染侧另钉)、
597///   `sidebar` 一族(无 shadcn 意义的 sidebar)、`destructive`(错误语义走 `semantic::ERROR`);
598///   参考站的 box-shadow / Outfit 字体 / badge tint 底 / uppercase 表头(0.8.0 记档不抄)。
599fn visuals_of(p: Palette, theme: Theme) -> Visuals {
600    let r_card = CornerRadius::same(CORNER_CARD);
601    let r_widget = CornerRadius::same(CORNER_WIDGET);
602    let mut v = match theme {
603        Theme::Dark => Visuals::dark(),
604        Theme::Light => Visuals::light(),
605    };
606    v.dark_mode = matches!(theme, Theme::Dark);
607    // shadcn 表没有斑马纹(旧 qss 的 alternate-background 同底也不条纹)——
608    // egui 的 `Visuals::dark()/light()` 自带 `striped = true`,双档都必须关掉(判据 `u1`)。
609    v.striped = false;
610    v.window_fill = p.bg1;
611    v.panel_fill = p.bg1;
612    v.extreme_bg_color = p.bg1; // 输入框底 = 页面底(同值)
613    v.faint_bg_color = p.bg2; // surface
614    v.hyperlink_color = p.accent4;
615    v.weak_text_color = Some(p.text3);
616    v.window_stroke = egui::Stroke::new(STROKE_WIDTH, p.border);
617    v.window_corner_radius = r_card;
618    v.menu_corner_radius = r_card;
619    // 不设(有意):text_edit_bg_color(缺省跟随 extreme_bg_color,同值设两遍是重复)、
620    // code_bg_color(ui.monospace 只换字体不带背景)、warn/error_fg_color(语义色在渲染侧
621    // 直绘)、slider/progress 相关(本 UI 0 处 slider、0 处进度条)。
622
623    let stroke1 = egui::Stroke::new(STROKE_WIDTH, p.text1);
624    // **表体口径(0.8.2 补的落点)**:egui 里普通 `ui.label` 取的是
625    // `widgets.noninteractive.fg_stroke`(style.rs:1066 `text_color()` → noninteractive),
626    // 而参考站的表体文字 = `--st-ink-soft`(td 那条)。旧双档把这一支也设成 `text1` →
627    // 裁定 C 引入的 TEXT2 只活在色板与判据里,**屏幕上从没落过**(0.8.2 探针实锤:
628    // 表头与表体全打 `text1`,三层塌成一层)。
629    // 三层落点自此固定:普通 label = `text2`(表体)· `.strong()`/控件文字 = `text1`
630    // (`strong_text_color()` = `widgets.active.fg_stroke`,style.rs:1077)· 弱文字 = `text3`
631    // (`weak_text_color`,已设)。
632    let stroke_body = egui::Stroke::new(STROKE_WIDTH, p.text2);
633    let border = egui::Stroke::new(STROKE_WIDTH, p.border);
634    // `WidgetVisuals` / `Selection` 不在 egui 的根 re-export 里(lib.rs:508 只出
635    // Style / TextStyle / Visuals / Spacing)→ 走 `egui::style::` 全路径。
636    v.widgets.noninteractive = egui::style::WidgetVisuals {
637        bg_fill: p.bg1,
638        weak_bg_fill: p.bg1,
639        bg_stroke: border, // 分隔线 / 窗框 / 网格线 / 行线
640        corner_radius: r_widget,
641        fg_stroke: stroke_body,
642        expansion: 0.0,
643    };
644    v.widgets.inactive = egui::style::WidgetVisuals {
645        bg_fill: p.bg4,
646        weak_bg_fill: p.bg4,
647        bg_stroke: border, // 表头带底 / 未选中 tab 头底 / 控件底 + 1px 描边
648        corner_radius: r_widget,
649        fg_stroke: stroke1,
650        expansion: 0.0,
651    };
652    v.widgets.hovered = egui::style::WidgetVisuals {
653        bg_fill: p.bg5,
654        weak_bg_fill: p.bg5,
655        bg_stroke: border,
656        corner_radius: r_widget,
657        fg_stroke: stroke1,
658        expansion: 0.0,
659    };
660    v.widgets.active = egui::style::WidgetVisuals {
661        bg_fill: p.bg6,
662        weak_bg_fill: p.bg6,
663        bg_stroke: border,
664        corner_radius: r_widget,
665        fg_stroke: stroke1,
666        expansion: 0.0,
667    };
668    v.widgets.open = egui::style::WidgetVisuals {
669        bg_fill: p.bg6,
670        weak_bg_fill: p.bg6,
671        bg_stroke: border,
672        corner_radius: r_widget,
673        fg_stroke: stroke1,
674        expansion: 0.0,
675    };
676    v.selection = egui::style::Selection {
677        bg_fill: p.accent2,
678        stroke: egui::Stroke::new(STROKE_WIDTH, p.text1), // 「选中项文字色」,不是边框色
679    };
680    // Qt 的按钮 / 链接是手型光标;egui 缺省 None(浏览器口径)。shadcn 也是浏览器口径的
681    // 手型光标(pointer-cursor: pointer),这一条两档同值。
682    v.interact_cursor = Some(egui::CursorIcon::PointingHand);
683    v
684}
685
686/// 涨跌色唯一入口(毛病 3 的语义半边:数字成列之外,颜色也必须按语义走,
687/// 不许各处自己 `if v > 0 {红} else {绿}` 现编)。
688/// `v > 0` → 涨(红);`v < 0` → 跌(绿);`v == 0` → 正文色(不染色)。
689/// 0.8.0:加 `ctx` 参数 = 取当前档的红/绿(hue 恒定,明度跟档)。
690pub fn rise_fall(ctx: &egui::Context, v: f64) -> Color32 {
691    let s = semantic_of(ctx);
692    if v > 0.0 {
693        s.rise
694    } else if v < 0.0 {
695        s.fall
696    } else {
697        palette(ctx).text1
698    }
699}