ek-ui 0.9.5

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
//! 设计令牌层(0.2.30,BD-UI-46;0.8.0 配色双档,BD-UI-61)。
//!
//! 为什么要有这一层:0.2.29 之前「颜色」散在两个地方 —— 主题函数里一份(五套
//! `Visuals`),渲染代码里另一份(`from_rgb` 直绘 50 多处:多红 / 空绿 / 警告橙 /
//! 错误深红 / 成功深绿 / 次级灰蓝 / 灰)。同一支语义色在不同表里各写各的,改一处
//! 不会传到另一处;而且「涨跌色」这种**必须恒定**的东西没有唯一源。
//!
//! 0.2.30 起规矩写死:**`src/` 里除本模块,`from_rgb(` 出现次数必须为 0**(机械钉
//! `u2_no_hardcoded_colors` 挡着)。所有色、字号、间距、控件尺寸、圆角从这里出。
//!
//! 0.8.0 起这一层多了一件事:**色板成对(深 / 浅两档),档由这里出,渲染侧只认访问器**。
//!
//! 裁定链(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)、判据面结构一律不动。
//!
//! 主题档位的裁定链(改判要留痕,免得日后看不出为什么有两档):
//! - 0.2.30 裁定 8(BD-UI-46):五套主题一键循环 → **减成一套 Qt深色**,菜单钮删除;
//! - 0.4.1 四期(BD-UI-57):那套换成 shadcn Nova 深色,`Theme` 枚举与循环钮一起退役;
//! - **0.8.0(BD-UI-61)改判**:用户实看判词「这个配色特别刺眼睛」+ 指定参考站
//!   (`http://127.0.0.1:8080`,Strata)要「白色和黑色两款,黑色我们本来就有」→
//!   深色档按参考站**重调**(A),新增浅色档,**菜单放一颗两档切换钮**(单点入口),
//!   档存 ek-cfg 存储根 `ns="ui" key="theme"`,缺省 = 深色(B:现行为零变化)。
//!   钉 `u1_menu_has_no_theme_button_and_nova_is_the_only_look` 随改判**改向**为
//!   「恰有一颗钮 + 两档底色都真落到画面上」。
//!
//! 参考站是怎么配的(实抓 `web/tokens.css` / `web/components.css`,不是印象):
//! 同名令牌两套值、换档整表换;底四支(`--st-bg` `#f9fafb` / `--st-surface` `#ffffff` /
//! `--st-surface-2` `#f7f7f7` / `--st-line` `#dee5ee`)、文字三支(`ink` `#221e1f` /
//! `ink-soft` `#303030` / `ink-muted` `#6f6f6f`);表头 = `th{text-align:left;
//! color:var(--st-ink-muted);border-bottom:1px solid var(--st-line)}`;
//! 语义色是 base + tint + text 三合一的一族。**不抄的也记死**:Outfit 字体(我们的字号
//! 阶梯 22/16/14 是 BD-UI-32 用户实看裁定)、box-shadow(egui 无阴影层,靠 1px 描边分层)、
//! badge tint 底(我们没有徽标面)、`text-transform:uppercase`(中文表头不适用)、
//! 系统配色探测(eframe/winit 0.33 无窗口级 API,缺省就是深色)。
//!
//! 色值源:旧深档 = `ColorPalette::shadcn_dark(Neutral)` 实测 26 支(DESIGN BD-UI-57 对照表);
//! 0.8.0 新深档/浅档 = 参考站色板逐支 + 本机 WCAG 相对亮度公式实算对比度定值(判据 `u10`)。
//!
//! 旧值逐支留在下面每条令牌的注释里(本仓纪律):CHANGELOG 0.4.1 四期有 Nova 整张对照表,
//! CHANGELOG 0.8.0 有本轮双档整张对照表,可回溯。

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

/// 界面档位(0.8.0,BD-UI-61)。**只有两档**,缺省 = 深色(用户裁定 B:现行为零变化)。
///
/// 持久化口径:落 ek-cfg 存储根 `ns="ui" key="theme"`,串值 = `as_str()`;
/// **只有手点过切换钮才落盘**(照参考站「只有点击才存 localStorage」),
/// 没落盘 = 缺省深色。脏串一律回落缺省(`from_str`),不给"未知档"留活口。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Theme {
    /// 深色(0.8.0 值 = 参考站深档配对;0.2.30–0.7.0 = shadcn Nova 深色)。
    #[default]
    Dark,
    /// 浅色(0.8.0 新增)。
    Light,
}

impl Theme {
    /// 落盘串值。
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Dark => "dark",
            Self::Light => "light",
        }
    }

    /// 读盘:脏串 / 空串 → 缺省深色。
    ///
    /// 有意**不**实现 `std::str::FromStr`(clippy 会建议):读盘口径是「脏值 = 缺省档」的
    /// 宽容映射,不产出 `Err` —— 落盘文件是用户手边可编辑的配置,给不出「解析失败」这回事
    /// (判据 u12 钉的就是这层宽容:别名进、脏值出 Dark)。
    #[allow(clippy::should_implement_trait)]
    pub fn from_str(s: &str) -> Self {
        match s.trim().to_ascii_lowercase().as_str() {
            "light" | "浅色" => Self::Light,
            _ => Self::Dark,
        }
    }

    /// 切换钮要的「另一档」。
    pub const fn flipped(self) -> Self {
        match self {
            Self::Dark => Self::Light,
            Self::Light => Self::Dark,
        }
    }

    /// 跨线程存放形态(`UiShared.theme` 是 `AtomicU8`,`0 = Dark`)。
    pub const fn as_u8(self) -> u8 {
        match self {
            Self::Dark => 0,
            Self::Light => 1,
        }
    }

    /// 读跨线程存放形态;脏值回落缺省深色(不给「未知档」留活口)。
    pub const fn from_u8(v: u8) -> Self {
        match v {
            1 => Self::Light,
            _ => Self::Dark,
        }
    }

    /// 这一档的色板。
    pub const fn palette(self) -> Palette {
        match self {
            Self::Dark => PALETTE_DARK,
            Self::Light => PALETTE_LIGHT,
        }
    }
}

/// 一整套底/文字/描边令牌(两支**同名字段、同支数** —— 判据 `u9` 逐支配对钉挡「浅档漏一支」,
/// 这是双档改造最高频的漏法)。
///
/// 字段名 = 角色名,与 `shadcn::` 里的旧令牌名一一对应,渲染侧不需要学两套名字。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Palette {
    /// 窗底 / 面板底 / 输入框底。
    pub bg1: Color32,
    /// 次级底(`faint_bg_color`,卡片)。
    pub bg2: Color32,
    /// 表头带底 / 未选中 tab 底 / 控件底(`widgets.inactive`)。
    pub bg4: Color32,
    /// hover 底(`widgets.hovered`)。
    pub bg5: Color32,
    /// pressed / active / checked(`widgets.active`)。
    pub bg6: Color32,
    /// 选中行底(`selection.bg_fill`)。
    pub accent2: Color32,
    /// 强调:选中 tab 下划线、链接、分隔条拖拽/悬停高亮。
    pub accent4: Color32,
    /// 一切描边(1px)。
    pub border: Color32,
    /// **行线专用**描边(0.8.2 落点对表:参考站 `th{border-bottom:var(--st-line)}` 与
    /// `td{border-bottom:var(--st-line-soft)}` 是**两支** —— 表头那根重、行线那根轻;
    /// 我们旧口径一根 BORDER 通吃,行线就比参考站重一档)。
    pub border_soft: Color32,
    /// 正文 / 标题。
    pub text1: Color32,
    /// 表体文字(0.8.0 新增,用户裁定 C)。
    pub text2: Color32,
    /// 次级文字 + 表头文字。
    pub text3: Color32,
}

/// 深档 = 参考站 Strata 深档配对(用户裁定 A「深色档按参考站重调」)。
/// 逐支旧值(Nova)写在 `shadcn::` 各令牌的注释里。
pub const PALETTE_DARK: Palette = Palette {
    bg1: shadcn::BG1,
    bg2: shadcn::BG2,
    bg4: shadcn::BG4,
    bg5: shadcn::BG5,
    bg6: shadcn::BG6,
    accent2: shadcn::ACCENT2,
    accent4: shadcn::ACCENT4,
    border: shadcn::BORDER,
    border_soft: shadcn::BORDER_SOFT,
    text1: shadcn::TEXT1,
    text2: shadcn::TEXT2,
    text3: shadcn::TEXT3,
};

/// 浅档 = 参考站 Strata 浅档配对。**逐支实算过对比度**(判据 `u10`,本机按 WCAG 公式算的):
/// 正文 `#221e1f` / 底 `#f9fafb` = **15.78:1**;表体 `#303030` = 12.63;次级与表头
/// `#5b6169` = 5.98(对表头带底 `#e9edf2` = **5.32**,达标线 4.5)。
///
/// 两处**有意偏离参考站**(不是漏抄,是照搬到 egui 上会出事的):
/// 1. 描边取实色 `#d5dde8`(参考站浅档 `#dee5ee`)—— 参考站的 `th` 底是白卡,我们的表头带
///    有底,同名义色在带底上不够;且旧「白 10% alpha 描边」在白面板上**等于没有描边**。
/// 2. 控件底/表头带 `#e9edf2`(参考站 `--st-surface-2` `#f7f7f7`)—— `#f7f7f7` 对
///    `#f9fafb` 面板底只有 1.05:1,滚动条把手与表头带**看不见**;取 `#e9edf2` 到 1.13:1,
///    与 1px 实色描边共同承担分层(参考站就是"弱底 + 描边"两件事一起干)。
pub const PALETTE_LIGHT: Palette = Palette {
    bg1: Color32::from_rgb(0xF9, 0xFA, 0xFB),
    bg2: Color32::from_rgb(0xFF, 0xFF, 0xFF),
    bg4: Color32::from_rgb(0xE9, 0xED, 0xF2),
    bg5: Color32::from_rgb(0xDD, 0xE4, 0xEC),
    bg6: Color32::from_rgb(0xD3, 0xDB, 0xE6),
    accent2: Color32::from_rgb(0xE8, 0xED, 0xF4),
    accent4: Color32::from_rgb(0x40, 0x5D, 0xE6),
    border: Color32::from_rgb(0xD5, 0xDD, 0xE8),
    border_soft: Color32::from_rgb(0xEE, 0xEE, 0xEE),
    text1: Color32::from_rgb(0x22, 0x1E, 0x1F),
    text2: Color32::from_rgb(0x30, 0x30, 0x30),
    text3: Color32::from_rgb(0x5B, 0x61, 0x69),
};

/// 语义色族(同名字段成对,`u9` 钉守着)。**hue 恒定,浅色档只降明度**(红线「金融语义
/// 不玩配色」;判据 `u11` 钉:两档里 RISE 必红、FALL 必绿、WARN 必橙、OK 必绿)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Semantic {
    /// 涨 / 多头 / 买 —— 红。
    pub rise: Color32,
    /// 跌 / 空头 / 卖 —— 绿。
    pub fall: Color32,
    /// 警告 / 待办提示 —— 橙。
    pub warn: Color32,
    /// 错误 / 失败 / 隔离 —— 深红(比涨沉一档,免得把「跌」和「错」混成同一支)。
    pub error: Color32,
    /// 成功 / 已启动 / 已连接 —— 绿(比跌沉一档)。
    pub ok: Color32,
    /// 状态类信息(总线状态、来源、中性提示)。
    pub info: Color32,
    /// 次级 / 弱化 / 口径说明。
    pub muted: Color32,
}

/// 深档语义色:除 `OK` 一支提亮(自审 R6 的签字破例)外**逐支零动**。
///
/// `OK` 为什么要破例:旧值 `#2E7D32` 对**旧纯黑底 `#0A0A0A` 只有 3.24:1**,对新的
/// `#0e1113` 更低 —— 这是现档就有的旧账,不是本轮引入。`u10` 阈值 4.5:1 要过,
/// 只能把明度提一档 → `#4a9e52`(对新底 5.69 / 对带底 4.82 / 对选中底 4.64)。
/// hue 与「绿 = 成功」的语义不变,动的只有明度。
pub const SEMANTIC_DARK: Semantic = Semantic {
    rise: semantic::RISE,
    fall: semantic::FALL,
    warn: semantic::WARN,
    error: semantic::ERROR,
    ok: semantic::OK,
    info: semantic::INFO,
    muted: semantic::MUTED,
};

/// 浅档语义色:hue 与深档同(红/绿/橙/深红/绿/蓝/灰),只把明度压下来,
/// 保证浅底上 ≥4.5:1(实算:涨 5.42 / 跌 5.20 / 警 5.67 / 错 6.63 / 成 7.40 / 状态 5.33)。
pub const SEMANTIC_LIGHT: Semantic = Semantic {
    rise: Color32::from_rgb(0xC0, 0x30, 0x30),
    fall: Color32::from_rgb(0x1F, 0x7A, 0x1F),
    warn: Color32::from_rgb(0x8A, 0x5A, 0x00),
    error: Color32::from_rgb(0xB0, 0x1A, 0x2E),
    ok: Color32::from_rgb(0x16, 0x65, 0x34),
    info: Color32::from_rgb(0x06, 0x68, 0xC2),
    muted: Color32::from_rgb(0x6F, 0x6F, 0x6F),
};

/// 告警(toast)三档皮肤 = 参考站语义色「base + tint + text」三合一那族的落点。
///
/// 为什么浅档必须换字色(不是"调一点点"):现值 WARNING `#FAAD14` 对浅底白卡
/// **只有 1.9:1**,在浅档上等于糊字。浅档取参考站的 tint 底 + text 字
/// (实算:警 5.51 / 状态 4.99 / 故障 6.07)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AlertSkin {
    /// INFO 字色。
    pub info: Color32,
    /// WARNING 字色。
    pub warning: Color32,
    /// ERROR 字色。
    pub error: Color32,
    /// INFO 软底(tint)。
    pub info_tint: Color32,
    /// WARNING 软底。
    pub warning_tint: Color32,
    /// ERROR 软底。
    pub error_tint: Color32,
    /// INFO 描边。
    pub info_border: Color32,
    /// WARNING 描边。
    pub warning_border: Color32,
    /// ERROR 描边。
    pub error_border: Color32,
}

/// 深档 = Python `AlertToast._LEVEL_COLORS` 原值(BD-UI-50 钉着的三支);
/// 软底/描边在深档沿现口径(底 = `bg2` 卡底,描边 = 级别色)。
pub const ALERT_DARK: AlertSkin = AlertSkin {
    info: alert::INFO,
    warning: alert::WARNING,
    error: alert::ERROR,
    info_tint: shadcn::BG2,
    warning_tint: shadcn::BG2,
    error_tint: shadcn::BG2,
    info_border: alert::INFO,
    warning_border: alert::WARNING,
    error_border: alert::ERROR,
};

/// 浅档 = 参考站 tint 底 + text 字(见上条注释的实算数)。
pub const ALERT_LIGHT: AlertSkin = AlertSkin {
    info: Color32::from_rgb(0x06, 0x68, 0xC2),
    warning: Color32::from_rgb(0x8A, 0x5A, 0x00),
    error: Color32::from_rgb(0xB0, 0x1A, 0x2E),
    info_tint: Color32::from_rgb(0xE8, 0xF4, 0xFF),
    warning_tint: Color32::from_rgb(0xFF, 0xF6, 0xE0),
    error_tint: Color32::from_rgb(0xFD, 0xEC, 0xEE),
    info_border: Color32::from_rgb(0xB9, 0xDC, 0xF7),
    warning_border: Color32::from_rgb(0xF0, 0xD9, 0xA0),
    error_border: Color32::from_rgb(0xF5, 0xB8, 0xC2),
};

/// `Theme` 在 `Context::data` 里的键(全仓唯一;`egui::Id::new` 不是 const fn,故做成函数)。
fn theme_key() -> egui::Id {
    egui::Id::new("ek-ui.theme")
}

/// 当前档(从 `Context` 读,缺省 = 深色)。
///
/// 为什么档住在 `Context::data` 而不是把 `Theme` 穿过三十个函数签名:渲染侧取色点
/// 全在拿得到 `ui.ctx()` 的闭包里,访问器一律 `tokens::rise(ui.ctx())`;
/// kittest 夹具跑的就是 `render_body` → 夹具改一行 `shared.set_theme(..)` 就能整帧换档。
pub fn theme_of(ctx: &egui::Context) -> Theme {
    ctx.data(|d| d.get_temp::<Theme>(theme_key()))
        .unwrap_or_default()
}

/// 装档(由 `apply_ui_style` 每帧调用;`Theme` 变了下一帧生效,点击自带 repaint)。
pub fn set_theme(ctx: &egui::Context, theme: Theme) {
    ctx.data_mut(|d| d.insert_temp(theme_key(), theme));
}

/// 当前档的色板。
pub fn palette(ctx: &egui::Context) -> Palette {
    theme_of(ctx).palette()
}

/// 当前档的语义色族。
pub fn semantic_of(ctx: &egui::Context) -> Semantic {
    semantic_of_theme(theme_of(ctx))
}

/// 按档取语义色族(渲染侧拿不到 ctx 的地方用这一支,例如构造期算好存着)。
pub const fn semantic_of_theme(theme: Theme) -> Semantic {
    match theme {
        Theme::Dark => SEMANTIC_DARK,
        Theme::Light => SEMANTIC_LIGHT,
    }
}

/// 当前档的告警皮肤。
pub const fn alert_of_theme(theme: Theme) -> AlertSkin {
    match theme {
        Theme::Dark => ALERT_DARK,
        Theme::Light => ALERT_LIGHT,
    }
}

// 渲染侧取色访问器(7 支语义 + 1 支涨跌)。**新代码不许直用 `semantic::` 常量** ——
// 直用 = 把深档值硬钉进渲染路径,浅色档下那一处就不跟档(判据 s90 抓的就是这一类)。
/// 涨 / 买 —— 当前档红。
pub fn rise(ctx: &egui::Context) -> Color32 {
    semantic_of(ctx).rise
}
/// 跌 / 卖 —— 当前档绿。
pub fn fall(ctx: &egui::Context) -> Color32 {
    semantic_of(ctx).fall
}
/// 警告橙 —— 当前档。
pub fn warn(ctx: &egui::Context) -> Color32 {
    semantic_of(ctx).warn
}
/// 错误深红 —— 当前档。
pub fn error(ctx: &egui::Context) -> Color32 {
    semantic_of(ctx).error
}
/// 成功绿 —— 当前档。
pub fn ok(ctx: &egui::Context) -> Color32 {
    semantic_of(ctx).ok
}
/// 状态类信息色 —— 当前档。
pub fn info(ctx: &egui::Context) -> Color32 {
    semantic_of(ctx).info
}
/// 次级/弱化灰 —— 当前档。
pub fn muted(ctx: &egui::Context) -> Color32 {
    semantic_of(ctx).muted
}

/// shadcn/ui Nova 深色色板 → **0.8.0 的双档「深档」**。
/// **全仓唯一允许写 `from_rgb(` 的地方**(判据 `u2`,浅档值就在上面 `PALETTE_LIGHT` 里)。
/// 令牌名沿用角色名(BG* = 底档、TEXT* = 文字档、ACCENT* = 强调档)—— 换的是值不是
/// 名字,渲染侧的取色口径不变;`PALETTE_DARK` 就是这一组常量的成对拷贝。
///
/// 0.8.0 换值的根由(裁定 A):旧值是 Nova 的**纯中性** —— `background` `#0A0A0A` 纯黑 +
/// `foreground` `#FAFAFA` 近纯白 = **18.97:1**,用户实看判词「刺眼」。参考站深档不是纯黑:
/// `#0e1113` 底(带蓝灰)+ `#eef1f3` 字 = **16.70:1**,层次靠 `surface` 一档一档抬。
/// **本轮治的就是这七支**(底 5 + 描边 + 文字 3),语义色除 `OK` 提亮外零动。
pub mod shadcn {
    use super::*;
    /// 窗底 / 容器底 / 输入框底。**0.8.0 `#0e1113`(参考站 `--st-bg`)**;旧 Nova `#0A0A0A`、
    /// 更早 qdark Gray.B10 `#19232D`。
    pub const BG1: Color32 = Color32::from_rgb(0x0E, 0x11, 0x13);
    /// 比背景亮一档的次级底(= `faint_bg_color`)。**0.8.0 `#161a1d`(参考站 `--st-surface`)**;
    /// 旧 Nova `#171717`、旧 Gray.B20 `#293544`。
    pub const BG2: Color32 = Color32::from_rgb(0x16, 0x1A, 0x1D);
    /// 表头带底、未选中 tab 头底、控件底(= `widgets.inactive.bg_fill`,也是滚动条把手底)。
    /// **0.8.0 `#1d2226`(参考站 `--st-surface-2`)**;旧 Nova `#262626`、旧 Gray.B40 `#455364`。
    pub const BG4: Color32 = Color32::from_rgb(0x1D, 0x22, 0x26);
    /// hover 底、选中 tab 底。**0.8.0 `#2a3036`(参考站 `--st-line`)**;旧 Nova `#404040`、旧 Gray.B50 `#54687A`。
    pub const BG5: Color32 = Color32::from_rgb(0x2A, 0x30, 0x36);
    /// pressed / checked。**0.8.0 `#343b42`(同一中性阶取一档)**;旧 Nova 合成色 `#525252`、旧 Gray.B60 `#60798B`。
    pub const BG6: Color32 = Color32::from_rgb(0x34, 0x3B, 0x42);
    /// 正文 / 标题 / 选中项文字。**0.8.0 `#eef1f3`(参考站 `--st-ink`,对 `BG1` 16.70:1)**;
    /// 旧 Nova `#FAFAFA`(对旧底 18.97:1,就是「刺」的来源)、旧 Gray.B130 `#DFE1E2`。
    pub const TEXT1: Color32 = Color32::from_rgb(0xEE, 0xF1, 0xF3);
    /// **表体文字(0.8.0 新增,用户裁定 C)** = 参考站 `--st-ink-soft` `#c7cdd3`(11.82:1)。
    /// 为什么要第三层:旧口径只有 `TEXT1`/`TEXT3`,标题与表体同色 = 层级塌一档;
    /// 参考站是 `ink`(标题)/ `ink-soft`(表体)/ `ink-muted`(次级)三层。浅档对应 `#303030`。
    pub const TEXT2: Color32 = Color32::from_rgb(0xC7, 0xCD, 0xD3);
    /// 次级文本 + **表头文字**(0.8.0 起表头走这一支,参考站 `th{color:ink-muted}` 口径)。
    /// **0.8.0 `#8e979f`(参考站 `--st-ink-muted`,对底 6.39 / 对带底 5.41)**;旧 Nova `#A1A1A1`、旧 Gray.B90 `#ACB1B6`。
    pub const TEXT3: Color32 = Color32::from_rgb(0x8E, 0x97, 0x9F);
    /// 选中行 / 选中文本底。**0.8.0 `#343b42`(= `BG6`,与 pressed 同档,旧口径照旧)**;
    /// 旧 Nova 合成色 `#525252`、旧 Blue.B40 `#346792`。
    ///
    /// 为什么不是执行途中一度采用的 `#20252a`(自审 R5 → 落地时被判据 `u1` 挡回,记档 R11):
    /// `#20252a` 让选中行的涨/跌字到 4.00/4.45,但它**比 hover 底 `BG5` `#2a3036` 更暗** →
    /// 「选中 = 比 hover 再亮一档」这条 BD-UI-57 的层级纪律被反着写(选中行比悬停行更不显眼),
    /// 判据 `u1_visuals_match_shadcn_nova_palette` 的底色阶梯钉(`hover < 选中`)当场红。
    /// **判据不放宽,改色板**:选中底取 hover 再上一档 = `BG6`,配套把选中行上会出现的
    /// 涨/跌/状态语义色按「明度跟档」提一档(见 `semantic` 的 R11 注释)→ 选中底上
    /// 涨 4.06 / 跌 4.56 / 警 4.16 / 成 4.02 / 文 10.00,层级与对比两头都立。
    /// 层级口径在两档同向:深档选中底更亮、浅档选中底更白(`#e8edf4` vs hover `#dde4ec`)。
    pub const ACCENT2: Color32 = Color32::from_rgb(0x34, 0x3B, 0x42);
    /// `primary` `#E5E5E5` —— 选中 tab 的 3px 下划线、链接色、分隔条拖拽/悬停高亮线。
    /// **0.8.0 深档不动(用户裁定 E)**;浅档对应值 = 参考站 `--st-focus` `#405de6`。
    /// 旧 Blue.B70 `#259AE9`。
    pub const ACCENT4: Color32 = Color32::from_rgb(0xE5, 0xE5, 0xE5);
    /// **一切描边**:窗框、分隔线、表头带下边线、网格线、行线、控件边框。
    /// **0.8.0 改实色 `#2a3036`(参考站 `--st-line`)**;旧口径 = 白 10% alpha
    /// (`oklch(1 0 0 / 10%)` → egui 预乘后 `#1A1A1A_1A`),Nova 之前是「描边 = BG4 同色」。
    ///
    /// 为什么脱掉 alpha(两处,浅档更要命):① alpha 白描边落在 `BG1`/`BG4`/`ACCENT2`
    /// 三种底上出来的灰不一样,同一根线在表头带与表体上不齐;② 浅档里白 10% 描边在
    /// `#f9fafb` 面板底上**等于没有描边**(参考站浅档本来就是实色 `#dee5ee`)。
    /// 「描边 = 一支」的纪律不变,变的只是它不再是 alpha 构造(判据 `u2` 的
    /// `from_rgba_unmultiplied` 那一支随之下岗,注释块同步改写,不留注释旧账)。
    pub const BORDER: Color32 = Color32::from_rgb(0x2A, 0x30, 0x36);
    /// **行线专用**描边 = 参考站 `--st-line-soft`(深 `#22272c` / 浅 `#eeeeee`)。
    /// 参考站把「表头那根线」与「行线」分成两支:`th{border-bottom:var(--st-line)}`、
    /// `td{border-bottom:var(--st-line-soft)}`。0.8.0 通吃一支 `BORDER` → 行线比参考站重
    /// 一档(0.8.2 用户复看看出「风格不对」的落点之一)。
    pub const BORDER_SOFT: Color32 = Color32::from_rgb(0x22, 0x27, 0x2C);
}

/// 告警三级色(0.3.2 Wave E,BD-UI-50)—— 值就是 Python `AlertToast._LEVEL_COLORS`
/// (alert_toast.py:45-49)。**0.8.0 起这一组 = 深档值**(浅档对应值在 `ALERT_LIGHT`),
/// 常量留着是给深档锚点与判据用的(`toast.rs` 的钉断言的就是「深档 = Python 原值一字不改」)。
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);
}

/// 语义色:**跨界面恒定,不随主题漂移**(金融语义不玩配色)—— 0.8.0 的口径是
/// **「hue 恒定、明度随档」**(判据 `u11` 钉着:两档里 RISE 必红、FALL 必绿、WARN 必橙)。
/// 这一组常量 = **深档值**;渲染侧取色走 `tokens::rise(ctx)` 等访问器(浅档才会跟档)。
pub mod semantic {
    use super::*;
    /// 涨 / 多头 / 买 —— 红。**0.8.0 明度提一档 `#f07575`**(旧 `#E05050`;R11:
    /// 选中底取 `BG6` `#343b42` 后,旧红在选中行上只有 2.94:1 → 提一档到 4.06,
    /// 页面底上 6.77。hue 不动:判据 `u11` 钉「RISE 必红」)。
    pub const RISE: Color32 = Color32::from_rgb(0xF0, 0x75, 0x75);
    /// 跌 / 空头 / 卖 —— 绿。**0.8.0 明度提一档 `#42bb46`**(旧 `#2E9E2E`;R11 同上:
    /// 选中行上 4.56 / 页面底 7.60)。
    pub const FALL: Color32 = Color32::from_rgb(0x42, 0xBB, 0x46);
    /// 警告 / 待办 / 「先去做某事」提示 —— 橙(0.8.0 不动:选中行上 4.16 已达标)。
    pub const WARN: Color32 = Color32::from_rgb(0xE0, 0x88, 0x10);
    /// 错误 / 失败 / 隔离 —— 深红(比 RISE 沉一档,免得把「跌」和「错」混成同一支)。
    /// **0.8.0 明度提一档 `#dc6060`**(旧 `#C03030`:页面底 3.34 不达标 → 5.31;
    /// 选中行上 3.18,`u10` 给 ERROR 的底线是 ≥3.0,它在页面底/卡片底上 4.6/4.9)。
    pub const ERROR: Color32 = Color32::from_rgb(0xDC, 0x60, 0x60);
    /// 成功 / 已启动 / 已连接 —— 绿。**0.8.0 提亮一档 `#54ac5c`**(自审 R6 签字破例的
    /// 落地值:旧 `#2E7D32` 对页面底只有 3.24:1 本就不达 AA;R11 后选中底变 `BG6`,
    /// 中途的 `#4a9e52` 在选中行上只 3.41 → 再提一档,选中行 4.02 / 页面底 6.71)。
    pub const OK: Color32 = Color32::from_rgb(0x54, 0xAC, 0x5C);
    /// 状态类信息(总线状态、来源、中性提示)—— 淡蓝灰(0.8.0 不动:选中行 4.72)。
    pub const INFO: Color32 = Color32::from_rgb(0x9A, 0xA8, 0xC0);
    /// 次级 / 弱化 / 口径说明 —— 中性灰。**0.8.0 提一档 `#949494`**(旧 `#888888`:
    /// 页面底 5.35 → 6.25;选中行上 3.74,这一支的用途就是「弱化/没事」,`u10` 给它 ≥3.0)。
    pub const MUTED: Color32 = Color32::from_rgb(0x94, 0x94, 0x94);
}

/// 字号阶梯(BD-UI-32 R1 用户实看裁定「整体字偏小」,对照 Python 版;
/// egui 缺省 13 / 12 / 18 / 12)。三档层级是毛病 7「无视觉层级」的解法:
/// 标题 / 正文 / 次级必须**肉眼可分**,不是只换个字重。
/// **0.8.0 不动**(双档改造不碰字号,参考站的 Outfit 字体不抄)。
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;

/// **格内左右内边距(0.8.0 对齐统一的锚,判据 s91)**:体表格每一格的 `Frame::inner_margin`
/// 与表头文字起笔**必须吃同一个常量**(旧口径两处各写 6,表头比表体靠左 6px = 用户报的
/// 「表头和表的内容对齐方式不一致」)。
pub const CELL_INSET_X: f32 = 6.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;

/// 数字格的唯一写法(0.8.0 对齐统一,判据 `t3`)。
///
/// 口径:**全左对齐 + 等宽数字**。旧口径数字列靠右对齐成列(六张表的 `RIGHT_COLS` 35 个
/// 格位),用户裁定「全部用左对齐」→ 右对齐那半退役,`tabular-nums` 那半(等宽)留下:
/// `format::price` 剥尾零(`101.0→"101"`、`99.7`),宽度天生不等,等宽只保「字符宽度一致、
/// 位数可数」,**保不了「小数点同一竖线」—— 这是全左对齐明码标出的代价**(方案 §5 R7)。
/// 日志窗早就是这个口径(`ui.monospace`)。
pub fn num_text(s: impl Into<String>) -> RichText {
    RichText::new(s).monospace()
}

/// 字号阶梯 + 间距 + 控件尺寸(与档无关的部分),一次装好。
/// 真实 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(全调色板最亮的一支,scroll_area.rs:1421-1425);false 时取
    //   `widgets.inactive.bg_fill` = BG4,hover → BG5、拖 → BG6(递进反馈仍在)。
    //   0.8.0 双档:浅档 BG4 = `#e9edf2` 对浅面板底 1.13:1 才**看得见**(参考站的
    //   `#f7f7f7` 在白面板上等于看不见 → 有意偏离,见 `PALETTE_LIGHT` 注释)。
    // - 占位口径不变(`floating = false`):Qt 的 `QScrollBar` 是占位的,不覆盖内容。
    style.spacing.scroll = egui::style::ScrollStyle {
        bar_width: SCROLL_BAR_WIDTH,
        bar_inner_margin: 2.0,
        ..egui::style::ScrollStyle::solid()
    };
}

/// 当前档的视觉(渲染侧与夹具的唯一入口)。
pub fn visuals_for(theme: Theme) -> Visuals {
    visuals_of(theme.palette(), theme)
}

/// 按档装视觉(0.8.0 双档;旧签名 `visuals()` = 深档,给缺省口径留一个不动的入口)。
pub fn visuals() -> Visuals {
    visuals_of(PALETTE_DARK, Theme::Dark)
}

/// 色板 → egui `Visuals`。映射口径逐条对过 shadcn 与参考站的 token 用法,不是凭印象:
/// - 页面 / 面板 / 输入框底 = `bg1`(两家都是所有容器同底,靠 1px 描边区分,所以
///   `window_fill` 与 `panel_fill` 同值 —— 与旧 qdark 同构,不是偷懒)。`faint_bg_color` = `bg2`。
/// - 控件底 = `bg4` → hover `bg5` → pressed `bg6`(pressed 是同一中性阶合成的下一档)。
///   描边一律 `border`:旧口径「`bg_stroke` 与 `bg_fill` 同色 = 视觉无边」随 BG4 拆分一起退役
///   —— 控件是**带 1px 描边**的。
/// - expansion 一律 0.0:hover 只换色不涨块,egui 缺省 hover/active 涨 1px。
/// - fg_stroke 宽度一律 1.0:hover/pressed 不加重文字,egui 缺省 1.5 / 2.0。
/// - 选中行与选中文本 = `accent2`,比 hover 再亮一档;选中 tab 的「未选中底 / 选中底 +
///   `accent4` 下划线」egui `Visuals` 里没有对应字段 → 由渲染侧 `tab_label` 直绘
///   (取色从这份 visuals 派生;判据 s67/v5)。
/// - `selection.stroke.color` 会被 egui 当**选中项文字色**用(style.rs:374)→ 取 `text1`。
/// - **不搬的东西(记死,免得日后被当成漏)**:字号阶梯 22/16/14 不退(BD-UI-32 裁定);
///   `ring`(egui 无 focus ring 字段)、`chart_1..5`(图表色板在渲染侧另钉)、
///   `sidebar` 一族(无 shadcn 意义的 sidebar)、`destructive`(错误语义走 `semantic::ERROR`);
///   参考站的 box-shadow / Outfit 字体 / badge tint 底 / uppercase 表头(0.8.0 记档不抄)。
fn visuals_of(p: Palette, theme: Theme) -> Visuals {
    let r_card = CornerRadius::same(CORNER_CARD);
    let r_widget = CornerRadius::same(CORNER_WIDGET);
    let mut v = match theme {
        Theme::Dark => Visuals::dark(),
        Theme::Light => Visuals::light(),
    };
    v.dark_mode = matches!(theme, Theme::Dark);
    // shadcn 表没有斑马纹(旧 qss 的 alternate-background 同底也不条纹)——
    // egui 的 `Visuals::dark()/light()` 自带 `striped = true`,双档都必须关掉(判据 `u1`)。
    v.striped = false;
    v.window_fill = p.bg1;
    v.panel_fill = p.bg1;
    v.extreme_bg_color = p.bg1; // 输入框底 = 页面底(同值)
    v.faint_bg_color = p.bg2; // surface
    v.hyperlink_color = p.accent4;
    v.weak_text_color = Some(p.text3);
    v.window_stroke = egui::Stroke::new(STROKE_WIDTH, p.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, p.text1);
    // **表体口径(0.8.2 补的落点)**:egui 里普通 `ui.label` 取的是
    // `widgets.noninteractive.fg_stroke`(style.rs:1066 `text_color()` → noninteractive),
    // 而参考站的表体文字 = `--st-ink-soft`(td 那条)。旧双档把这一支也设成 `text1` →
    // 裁定 C 引入的 TEXT2 只活在色板与判据里,**屏幕上从没落过**(0.8.2 探针实锤:
    // 表头与表体全打 `text1`,三层塌成一层)。
    // 三层落点自此固定:普通 label = `text2`(表体)· `.strong()`/控件文字 = `text1`
    // (`strong_text_color()` = `widgets.active.fg_stroke`,style.rs:1077)· 弱文字 = `text3`
    // (`weak_text_color`,已设)。
    let stroke_body = egui::Stroke::new(STROKE_WIDTH, p.text2);
    let border = egui::Stroke::new(STROKE_WIDTH, p.border);
    // `WidgetVisuals` / `Selection` 不在 egui 的根 re-export 里(lib.rs:508 只出
    // Style / TextStyle / Visuals / Spacing)→ 走 `egui::style::` 全路径。
    v.widgets.noninteractive = egui::style::WidgetVisuals {
        bg_fill: p.bg1,
        weak_bg_fill: p.bg1,
        bg_stroke: border, // 分隔线 / 窗框 / 网格线 / 行线
        corner_radius: r_widget,
        fg_stroke: stroke_body,
        expansion: 0.0,
    };
    v.widgets.inactive = egui::style::WidgetVisuals {
        bg_fill: p.bg4,
        weak_bg_fill: p.bg4,
        bg_stroke: border, // 表头带底 / 未选中 tab 头底 / 控件底 + 1px 描边
        corner_radius: r_widget,
        fg_stroke: stroke1,
        expansion: 0.0,
    };
    v.widgets.hovered = egui::style::WidgetVisuals {
        bg_fill: p.bg5,
        weak_bg_fill: p.bg5,
        bg_stroke: border,
        corner_radius: r_widget,
        fg_stroke: stroke1,
        expansion: 0.0,
    };
    v.widgets.active = egui::style::WidgetVisuals {
        bg_fill: p.bg6,
        weak_bg_fill: p.bg6,
        bg_stroke: border,
        corner_radius: r_widget,
        fg_stroke: stroke1,
        expansion: 0.0,
    };
    v.widgets.open = egui::style::WidgetVisuals {
        bg_fill: p.bg6,
        weak_bg_fill: p.bg6,
        bg_stroke: border,
        corner_radius: r_widget,
        fg_stroke: stroke1,
        expansion: 0.0,
    };
    v.selection = egui::style::Selection {
        bg_fill: p.accent2,
        stroke: egui::Stroke::new(STROKE_WIDTH, p.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` → 正文色(不染色)。
/// 0.8.0:加 `ctx` 参数 = 取当前档的红/绿(hue 恒定,明度跟档)。
pub fn rise_fall(ctx: &egui::Context, v: f64) -> Color32 {
    let s = semantic_of(ctx);
    if v > 0.0 {
        s.rise
    } else if v < 0.0 {
        s.fall
    } else {
        palette(ctx).text1
    }
}