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}