ek-ui 0.3.3

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
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
# DESIGN —— ek-ui(S1b 常驻观测界面)


Frozen ref: docs/ek-ui-station-plan.md(DV 表 + S1b 段验收)。本档只记**结构决定**与
执行期钉。形式沿用 ek-dm/ek-station 的 BD-U1.. 编号加序。

## 结构


- `state.rs`:`UiShared`(总线面写 / 窗口面读)+ `LogRing`(cap 512,挤旧 +
  spilled 单调计数)+ `BusSnap`(svctl.list 行 + 逐插件服务 id 目录)。
  锁粒度:每次操作 lock/clone,不跨帧持锁;`snapshot()` 返回 `Arc<BusSnap>` 零拷贝。
- `plugin.rs`:`UiPlugin`("ui"):routes = `eLog`/`ePlugUp`/`ePlugDown`/`eTimer` +
  `eTick.`(行情带唯一源)+ `eContract.`/`eContractFinish`(查询合约窗数据流,
  ek-ctp 登录后自动查询,UI 只收不催)。
  - eLog:payload `LogData`,`is_warning_or_above` 打 WARN 标签(+UCH 转储环形);
  - ePlugUp/Down:payload `PlugNotice` → 单行 host 日志 + rev;
  - eTick./eContract.:payload 直落 `tick_store`/`contract_store`(键 EX.symbol);
  - eTimer:`ask(svctl.list, 2s)` + 逐插件 `ctx.services_of` → `set_snapshot`;
    ask 失败进日志面,不堆 panic。
- `render.rs`:`render_body(ctx, &UiShared)` 唯一渲染入口(L2;v5 面板化,
  BD-UI-27/29):TopBottomPanel(菜单)→ TopBottomPanel(行情带,resizable)→
  SidePanel(下单面板 + 盘口,resizable)→ CentralPanel(2×2 监控格,响应式)→
  弹窗面(K线/运维/CTP/查询合约,egui::Window,面板之后)。
- `runtime.rs`:
  - `ui_kinds(&Arc<UiShared>)` = station 缺省表 + ui 工厂;工厂闭包绑定**同一个**
    shared → ui 插件被 svctl 摘除/再注,窗口观测态不丢(状态在 shared 不在插件体);
  - `spawn_host_watcher`:`sleep 120ms` 轮询 stop 位 → `engine.close_all(3s)`;
    close 顺序(逆 start 序)仍由宿主引擎决定,观察线程只送信号;
  - `run_station_ui`:boot(不 start 由 boot 内部? 否—— boot 只装配)→ `engine.start()`
    → 观察线程 → eframe `run_native`;关窗 `on_close_event → request_stop` →
    join 观察线程 → 返回。启动失败(start Err)以 `Err(String)` 上抛(供 S1c
    launcher 的失败信号,不在窗口内吞错)。

## 执行期钉


- **BD-UI-9(UI 即插件 / 双线程形貌)**:总线 worker 写、eframe 主线程读,以
  `Arc<UiShared>` 相连;不加自制通道、不退回向量共享消息队列。
- **BD-UI-10(L2 单渲染入口)**:`render_body(ctx, &UiShared)` 被真实 App 与
  kittest 同调;面板组合在 render_body 内(BD-UI-29),测试走
  `Harness::builder().build(|ctx| …)`(ctx 级,不再包 CentralPanel)。
- **BD-UI-7(四件套版本锁)**:eframe = egui = egui_kittest = `0.33.3`;
  egui-charts 0.2.0 进入点定为 S4(K 线页),进入时与四件套重验一版。
- **BD-UI-11(关窗协议)**:`App::on_close_event` 只置 stop 位;close_all 由
  观察线程完成 —— 窗口永不直接持有 `MainEngine::close_all` 调用链。
- **BD-UI-12(kittest 用法纪律,0.3.2 改口径)**:判据四类 —— ① AccessKit 语义树
  (节点在不在、标签是什么、`Node::rect()` 几何);② 状态层(`UiShared` 字段/访问器,纯单测);
  ③ 源码机械扫描(色值只在 `tokens`、显式开关、格式只走 `src/format.rs`);
  ④ **本帧绘制指令**(`egui::FullOutput.shapes` 的实心矩形 / 线段 / 文字形状 + `clip_rect`)。
  **图像快照与像素扫描作废**:`harness.render()` / `harness.snapshot()` /
  `UPDATE_SNAPSHOTS` 一律不再用(本机没有截图也没有读图能力,详见本文件末「0.3.2 口径变更」
  与交接报告 §17)。
- **BD-UI-13(纯观测零服务)**:ui 插件 `services()` 返回空;管制面唯一在 svctl
  —— 界面无特权路径。动态 add 的 ask 窗口期(`on_init` 后)与 headless 同一契约。
- **BD-UI-14(启动器协议,与 ek-station bin `--from/--ready-file` 同口径)**:
  清单 = 行式文本(`#` 注释;空行跳过;行内空白拒);就绪标记 = `ready\n`;
  spawn 分离式(stdin/stdout 静默 + Windows `CREATE_NO_WINDOW` + stderr 管道,
  **stderr 生存期 ≤ 64KB** —— 子进程不朝 stderr 泼流,违约即协议故障);
  等待 = 50ms 轮询标记 + 存活检查,30s 上限(超时 kill);失败分类 `Exited{code,
  stderr_tail}` / `NoReadySignal{timeout}`;成功路径 **drop Handle = 分离脱管**。
- **BD-UI-15(清单解析地面)**:pick.rs 与 ek-station main.rs 的 `--from` 解析
  S1c 各自落地(等语义);S3 toml-lite 重构时统一抽到共享模块(单点 PR),避免
  现在跨 crate 发版拖 III 制订的节奏。
- **BD-UI-16(pick 窗两线程形貌)**:UI 线程只画与翻位;spawn+wait 在工作线程
  (30s 不冻 UI);结果回槽 `RunShared`;App 见果即关窗,
  收场归 `run_pick_launcher`(join + 读槽)。ui 插件自动补入 pick 后配置,
  其余勾选即装配面(与 boot 同表)。
- **BD-UI-17(隔离红标的闭环面)**:`ePlugQDown.<名>` 是定向广播(未知名无法
  预订阅 —— 严格 exact-type 路由),UI 侧红标走快照行状态扫描
  (`is_quarantined`,1s 节拍内落实);隔离行仍可重注入(svctl.start 对隔离行
  被 Rejected(重名)拒;既定语义链 = 先 remove 再 start)。红标卡件 trace:
  `ek-core::lifecycle::EVENT_PLUG_DOWN_PREFIX("ePlugQDown.")` 常量名与后缀型
  一致,但仅作 reference,不预订阅。
- **BD-UI-18(操作指令的唯一通道)**:窗口面动作只 `push_cmd(UiCmd)`,
  NOT 直 ask —— `PluginContext` 只在 ui 插件 worker 可用;eTimer 拍先辞行
  指令再拉快照;移除走 `pending_confirm` 单槽二步(Armed→Fired;Switched =
  目标迁移),取消钮可擦;重注入为单步(加性动作);拒绝类回执
  (`Rejected/NoSuchName`)进日志面板,不在 UI 抛错。
- **BD-UI-19(配置面 → 2026-10-17 用户定盘撤销,零配置口径)**:原 S3 落地过
  toml-lite + `setting/global.toml` 融入读法 + 状态条 + 脱敏(cfg.rs/cfg 测试
  9 例/s7 kittest 1 例)。**现行口径:ek-ui 零配置、宿主零配置** —— 不读任何
  配置文件,界面本身无设置;插件结构只由 `--from` 清单或 demo 缺省决定。
  需要配置的插件(唯一 = ek-ctp 虚拟账号),配置面长在插件自身,激活时在插件
  内完成(见「配置面撤销记录」);宿主/界面不代读不转发。cfg.rs 整块删除;
  脱敏纪律(redact)随配置面一同撤销(无配置输入即无秘密暴露面;插件自带
  配置面若需展示账号,脱敏规则届时由该插件自持)。
- **BD-UI-20(S4 行情面口径)**:主题面(数据)与身体面(交互)分离——
  `MarketStore`持直播线上屏(eBar./eBarUpdate.),(symbol,interval) 格缓存
  4096 丢头保尾,帧/更新/回灌三计数 = 「刷新不丢帧统计可见」的三处钉面;
  K线 chart 实例**每帧重建**(v1 不保 pan/缩放;首帧快照入库即 review 约定
  —— BD-UI-8);图表层引用在本 crate 集中(`render::render_kline_cell`
  一点换器,egui_charts→egui_plot 退守路线只动此处,BD-UI-4)。
- **BD-UI-21(S4 直播面工厂纪律)**:UI 宿主的 dm 工厂在 `ui_kinds` 内换建
  (real_time=true),ek-station headless 的 rt-off 工厂**不动** ——
  宿主差异收在各自工厂表,不回写 ek-station、不新增 svctl 控制面;
  dm 工厂 real_time 门为装配参数(缺省 true;UI 观测面要实时门 —— 非配置文件
  输入,零配置口径,BD-UI-19 现行版)。
- **BD-UI-15 兑现记录(S3)**:清单地面统一抽世到 `ek-station 0.1.1::pickfile`
  (已发布;`ek-ui::pick` 变薄转发,launcher/bin/测试共一处,双实现消灭)。
- **BD-UI-22(S5 纸面网关契约纪律)**:sim 前三口直接复用 ek-ctp 的
  Req/Reply 类型(SubscribeReq/SendOrderReq/CancelOrderReq 及回包)——
  同构 = 同一类型面,接线换 `sim.` 即插真网关;第四口 `query_rate` 为 sim
  自定(ek-ctp 0.2.0 无此口,CTP 流控语义镜像)。撮合规则钉:限价成交于
  委托价、市价成交于 close/last、bar.volume = 本拍成交预算、挂单 FIFO ——
  手数排队由此可断言。撮合器 `SimMatcher` 纯逻辑零总线(金样本直断),
  插件壳只做路由/锁/上总线(锁内撮合锁外 put,与 DmPlugin 同形)。
  sim 不引 ek-oms:成交经 eOrder./eTrade./ePosition. 总线入账由 OmsPlugin
  被动完成(插件不直调铁律不破)。
- **BD-UI-23(S5 监控页读数纪律)**:监控页读数 = ui 插件 eTimer 拍 ask
  `oms.get_all_*` 全量查询帧(与 headless 同源契约;oms 不在 → 空帧不抛错);
  下单/撤单/流控三口走 UiCmd → ask `sim.*`(窗口面零直调)。撤单钮只挂
  ACTIVE_STATUSES 行(与 ek-data 同口径)。kittest 点击纪律:**`click_accesskit`
  取代坐标 `click`** —— 坐标点击对折叠线下(clip 外)节点无效,accesskit
  动作可达不可见控件;`get_all_by_label` 精确匹配、`query_by_label_contains`
  子串匹配且多命中即炸,撞名列按语义选面。
- **BD-UI-24(S6 CTP 手工配置面,2026-10-17 集成测试战役)**:「插件需要配置 →
  配置面在插件自身」的正向界面落地 —— CTP 段 9 输入位全部**人敲**(界面与宿主
  不读文件;密码/AuthCode `password(true)` 掩码不回显),`连接 CTP` →
  `UiCmd::CtpConfigure(CtpForm)` → ask `ctp.configure`(ek-ctp 0.3.0
  D-CONFIGURE:校验/守卫复位/发起归插件,md 账号回落也在插件侧裁决 —— 表单
  `to_configure_req` 不预处理);`订阅行情` → ask `ctp.subscribe`(订阅位
  逗号/空白解析,空位红字不入列)。`UiCmd` 携 `CtpForm`(全 String 可 PartialEq;
  ek-ctp `ConfigureReq` 无 PartialEq,抬线在 ui 插件出队点一次转换,装版不动)。
  资金小节 = `oms.get_all_accounts` 帧(可用 = balance−frozen 由 AccountData
  自算)。native 经 feature `ctp-native` 透传(默认关;实连战役开,工厂
  `with_native` 在 ek-station 侧)。装配序纪律:要观察插件 on_start 的日志,
  ui 须在清单里先于该插件(观察序 = 装配序,c0 钉)。
- **BD-UI-25(票据网关选择子,2026-10-17 战役)**:票据新增「网关」位(空 =
  `sim` 纸面;填 `ctp` = ask `ctp.send_order`/`ctp.cancel_order`);`UiCmd`
  随单携 `gateway`;行级撤单钮跟随委托来路 `gateway_name`(空兜底 sim)。
- **BD-UI-26(CJK 字体回退,2026-10-18 乱码根因)**:epaint 0.33 缺省字体组
  (Hack/Ubuntu-Light/NotoEmoji/emoji-icon)**无 CJK 字形** → 中文豆腐块。
  `fonts::apply_cjk_fonts` 启动时从系统字体候选表装载一枚 CJK 字体追加
  Proportional/Monospace 两族末位(**只收纯 .ttf/.otf** —— epaint 走 ab_glyph,
  不吃 .ttc 集合且解析失败即 panic;VF 按缺省实例解析,排位靠后);两处
  run_native 取帧闭包接线(runtime + pick 窗)。验收三钉:
  f0 字形 API(22 常用汉字 has_glyph)、s16 汉字密集页快照 baseline、实窗
  stderr 证据行 `[ek-ui] CJK 回退字体已装载: <path>`。界面文案保持中文单语
  (用户定盘:要的是不乱码,不是双语切换)。
- **BD-UI-27(v4 带框架 + 订阅两入口,2026-10-18 布局重做定稿)**:
  ① **带框架(Python 主界面等价)**:菜单栏(运维|网关|查询合约|帮助)→ 行情带
  (全宽,tick 唯一源,交易所二级 tab,双击行弹 K线窗)→ 交易带(左下单面板
  9 字段 + 委托/全撤 + 右 2×2 监控格「委托|未成交」「成交」「持仓」「资金|日志」);
  回灌/票据界面面退役(总线面 UiCmd::Reload 保留);默认窗 1280×860。
  ② **订阅两入口(用户裁定「订阅不在网关窗定死」)**:下单面板代码框回车 =
  CtpSubscribe(bare symbol,Python returnPressed 等价);菜单「查询合约」弹窗
  (Python ContractManager 等价:查询框留空 = 全部 + 勾选 + 订阅选中)。CTP 网关窗
  无订阅字段。UI 对合约流只收不催(无催查询总线口)。
  ③ **egui 0.33 帧内 sizing 退让钉(实窗截图审出)**:帧内 Grid 的 sizing 预估遇
  ComboBox 高行会退让,网格后挂的面板内容会叠回网格 → 下单表单 = 网格(按钮/
  提示语都是网格行,网格后不挂内容),盘口独立帧,交易带整体包 ScrollArea 保小窗
  可达(D7 口径)。④ **kittest 钉**:建构面统一 `harness_for()` 1280×860(实窗
  同源);点击帧断言用 `harness.step()` 单帧(`run()` 空闲帧会撤走单帧红字);
  撞名 label(委托钮/tab/表题)用 `get_all_by_label` 按渲染序取。
- **BD-UI-28(第五轮审计,2026-10-18 复审修正)**:
  ① **回车订阅真缺陷(实码修复)**:egui 单行 TextEdit 遇 Enter 即
  `surrender_focus`,`has_focus()` 在检查时已 false —— 原 `has_focus +
  key_pressed` 口径下回车订阅**实窗从不触发**;改规范口径 `lost_focus() +
  key_pressed(Enter)`(s20 钉实测)。回车输入带 `EX.` 前缀则剥成裸码并回写
  表单(与查询合约窗同口径,ctp.subscribe 收裸码)。
  ② **悬空面清除**:`selected_symbol` 四处写零读(注释声称「联动下单面板」
  未兑现)→ 字段/访问器删除;行情带行**单击 = 带入下单面板**(方案「行单击
  = 选中合约」落地,s8 钉);委托行/持仓行单击无动作(Python 同口径)。
  ③ **委托行双击 = 撤单**(Python OrderMonitor `itemDoubleClicked→
  cancel_order` 等价,活动行限定,s11 钉;原实现双击弹 K线为偏离,已撤 ——
  K线入口唯一在行情带双击)。④ **表列补全(Python 蓝图逐值抄录)**:委托表
  13→17 列(+名称/交易所/类型/来源=reference)、成交表 10→12(+名称/交易所)、
  持仓表 11→16(+名称/交易所/在途/市值=market_value/成本=price);数据面
  OrderData/TradeData/PositionData 字段全在场,零新增依赖。⑤ **tick_of
  交易所感知**:交易所非空 = EX.symbol 精确匹配(跨所同码不串),空 = 仅按
  代码。⑥ **kittest 双击口径**:`step()` 每事件一帧且缺省 step_dt=0.25s →
  两击释放间隔 0.5s > egui `max_double_click_delay` 0.3s → 双击钉用
  `with_step_dt(0.1)` 建构(s11);同帧四连击不可用(每事件一帧)。Node 借用
  harness 树,取 rect/id 后再 step(借用检查);`Role::TextInput` 非 TextBox。
  ⑦ **Frame 布局泄漏钉(s21 满表快照显形)**:`Frame::show` 继承父级
  `ui.horizontal` 的横向布局 → 2×2 格内 tab 行/表题/宽网格横流挤同一行
  (空表期隐形,满表现形)→ `group_frame` 内容强制 `ui.vertical`(行情带同款
  口径:短行成行,宽网格末位溢出走横向滚动);下单面板帧不动(其内容 = 网格 +
  罕见 err label,横流反而护住 BD-UI-27 叠行钉)。委托表表题撤除(tab
  「委托|未成交」即 Python 表身份,三重撞名降为两面,s12 口径同步)。
  满表视觉钉 s21(委托 17/成交 12/持仓 16 列入 baseline)。
- **BD-UI-29(面板化重构,布局 v5;实窗演示暴露 + 官方文档核对)**:实窗
  1280 演示暴露三症:① 交易带定宽 250+140+480+480+间距 ≈ 1438 > 1280 →
  右列裁切且带级 ScrollArea 只开纵向 → 裁掉内容不可达(违 BD-UI-27 可达
  口径);② 2×2 格定高 250 实际只渲染 ~85px → 底部死区 ~300px;③ 菜单/两带
  全挤单 CentralPanel 手工嵌套,无 Python splitter 语义。
  - **机制实锤(dbg 探针)**:`[row] avail=[9984, 18]` —— egui horizontal 行
    成员的可用高度初始只有一行高(interact_size),**高度分配不沿横向向下
    传播**,行按内容收缩 → 格内 `ScrollArea.max_height` 被钳到最小滚动尺寸,
    定高失效(②的根因;①为定宽算术,1438>1280 可复现)。
  - **官方口径核对**(egui 0.33.3 源码模块文档 = docs.rs 同源;crates.io 包
    不含 examples/,egui.rs 书为 JS 渲染页):panel.rs「面板是放控件的正规
    区域;顶层面板不得互相嵌套;CentralPanel 必须最后加;Window 挂面板之后」;
    `TopBottomPanel::resizable/default_height`、`SidePanel::resizable/
    default_width` = QSplitter 官方等价;resizable 前提 = 内容吃满可用空间
    (ScrollArea auto_shrink(false) 满足)。
  - **落地**:render_body 改收 `&egui::Context` 自开四面板(菜单 → 行情带
    240 可拖 → 下单侧栏 250 可拖[下单面板 + 盘口纵排,Python TradingWidget
    内序] → CentralPanel 最后 → 弹窗);2×2 格响应式 `allocate_exact_size`
    预留 (可用宽-间距)/2 × (可用高-间距)/2 矩形 + `new_child` 在预留矩形内
    开 Frame+ScrollArea(高度才吃满;`add_sized` 收 impl Widget 不适用闭包
    内容,已试撤);`group_frame` 保留 `ui.vertical` 泄漏钉(BD-UI-28⑦ 延续)。
  - **BD-UI-28⑦「下单面板帧不动」被本钉取代**:面板化后下单侧栏内容天然
    纵向,err label 后置改由 ScrollArea 吸收 sizing 退让(BD-UI-27 口径不变,
    护垫从「横流侥幸」换成「官方吸收面」)。
  - **钉**:s3 加结构钉(右列存在贴边不裁切/底行沉底无死区/侧栏在左/两列
    等宽随窗分配,先于字体让位执行);快照 s3/s10/s16/s21 重基线;行为钉
    s0–s20 全数不动全绿;kittest 建构面 `build_ui` → `build`(ctx 级)。

## 与 ek-station 的边界


ek-ui 依赖 `ek-station = "0.1.7"` / `ek-ctp = "0.5.0"`(装版;禁 path —— 同纪律)。ek-station
本 bin 保持 headless 不引 ek-ui;UI 总装配在 ek-ui(`ui_kinds` 复用 `default_kinds`
+ ui 工厂),launcher(S1c)也在 ek-ui 侧。

## 审计修正记录(2026-10-17 全面复审)


- **BD-UI-22 补钉(撮合两守卫)**:① **tick/bar 双重计价防护** —— tick 成交
  挂账 `tick_filled`,bar 收口预算 = bar.volume − 本段 tick 已成(同一笔市场
  量只吃一次;u5 钉);② **bar 单调守卫** —— 同 symbol `bar.datetime` 非严格
  递增的帧(重复/重放)直接忽略,不重复撮合(u6 钉);③ ask 臂锁面开块
  (match 暂存守卫会跨臂存活 —— 锁内造单锁外上总线,与 on_mail 同形)。
- **ek-station 0.1.2(pickfile BOM 剥离)**:Notepad 系 UTF-8 BOM 使首行注释
  识别失效 → 整表 BadLine 拒启(活跑实测撞到);read 剥 BOM,p3 钉。
- 复审确认未动面:ek-station headless 工厂表(rt-off)未波及;已发布六仓仅
  rustfmt 1.8.0 纯格式对齐(无语义变);setting/global.toml 只读;
  ek-ctp/ek-oms 契约类型只引用未改。

## 配置面撤销记录(2026-10-17 用户定盘)


- 用户口径:**界面零配置 + 宿主零配置** —— ek-ui 不读任何配置文件;station
  侧也不预读配置转发。插件结构只由 `--from` 清单或 demo 缺省决定。
- **配置归插件自身(定盘)**:激活列表(pick 窗勾选 / svctl start 重注入)时,
  哪个插件需要配置,配置面就在该插件内部完成(插件自带配置面/配置服务);
  宿主与界面都不代读、不转发、不集中。暂时不需要任何多配置机制。
- 全生态唯一需要配置的插件 = ek-ctp(虚拟账号)。其配置面归 ek-ctp 插件自身
  (S6 接线时按此定盘落地:激活 ctp 时配置在插件内完成,不落共享配置树)。
- 落地:cfg.rs(toml-lite/UiConfig/locate_setting/redact)+ CfgStatus 状态条 +
  `--config` 形态 + 发现链整块删除;cfg 测试 9 例 + s7 kittest 1 例随撤;
  测试 51 → 41。**setting/ 目录已整树清理(2026-10-17 定盘)**:global.toml +
  backtest/common/live/test 分层样例(含凭据档)全部删除——插件配置自持,
  工作区不留共享配置文件;Python 侧 c:\dev\enkeep_trade_setting 原档不在
  清理范围(只读纪律,原样未动)。

## BD-UI-30(0.1.1 薄下单者,OMS 中枢链,2026-10-19)


- **裁定**:下单/撤单直发 `{gw}.send_order`/`{gw}.cancel_order` 的旁路口
  **删除**——UI 是薄下单者,管制面收敛到 oms 两服务:
  `UiCmd::SendOrder{req, gateway, mode}` → `oms.submit_order`(五段管线:
  入口校验/拆单槽位/风控槽位/发送段/记账段;parent 聚合 SENT|PARTIAL|REJECTED
  逐腿显形进日志);`UiCmd::CancelOrder{orderid, gateway}` → `oms.cancel_order`
  (守卫 4000 不可绕过;symbol/exchange 不再随令携带,oms 订单缓存解;
  全撤 = 界面侧逐个扇出到同一守卫口)。BD-UI-23/25 的「ask sim.*/ctp.*」
  口径自此被取代(历史记录保留)。
- **「自动开平」勾选(OrderForm.auto_split,默认开)**:拆单四态的 UI 映射 =
  Auto(勾选)/None(取消,原样旁路);风控与守卫不可旁,拆单可逐请求旁
  (DESIGN ek-oms §6.2 口径的 UI 面)。s22 钉勾选双向 → mode 逐位。
- **装配口径统一**:ui_kinds 复用 station 0.1.7 `default_kinds`——oms 已装配
  ek-risk 三闸(standard_gates 缺省限额),面板与 headless 同一台 oms。
- **fixture 退役**:ui_converter_chain 从「offset 夹具进链」升格真管线链
  (拆单器入驻 oms 管线,夹具失去存在意义;dev-dep ek-converter 删除)。
  顺带实锤忠实初始化口径:无成交史的首轮 `ePositionDone.` 只记时刻等下一轮
  (原稿 1268-1288/1282-1285),第二轮距首轮 ≥3s 才置位——链测补第二轮。
- **实连钉**:ui_ctp_live 第 13 步(有仓则 CLOSE 经管线 = CLOSETODAY 实连
  证据;7×24 快照市无成交 = OBSERVE 如实记档)。13 步 110s 全收场。
- **依赖**:ek-oms 0.2.1→0.3.1、ek-station 0.1.5→0.1.7(强制 bump,防同构建
  两份 oms)。

## BD-UI-31(0.2.0 界面体验轮,2026-10-19;用户四点裁定)


- **R1 菜单一键直达**:「运维/网关/查询合约」menu_button→下拉→入口钮的
  两层结构废除,菜单钮即入口,单击直接弹窗(「帮助」保留菜单——多行内容)。
  破坏性:三个入口钮 pub 常量删除(标签 = 菜单名)。
- **R2 成交表方向上色**:方向列空绿/多红,补齐与委托/持仓表的一致口径
  (此前成交表方向为裸文本 = 遗漏,用户实看指出)。
- **R3 表格行选中 + 悬停跳色**(`UiShared::row_selected/select_row` 新口 +
  `paint_row_bg` 渲染工具):委托/未成交/成交/持仓/查询合约窗行首格单击 =
  选中行(蓝调半透明跳底,每表一行,换行即换选);行情带选中行 = 下单面板
  当前合约(带入即跳色,不另存态);全表悬停 = 淡灰底。行首格既有双击动作
  (撤单/带入/K线)全保留——egui 双击先经 clicked,选中顺带发生,无冲突。
  叠底为半透明(行文字先画,叠后仍可读)。
- **R4 标题文字撤除**:「行情带(唯一源…)」「下单面板」「盘口(合约)」三处
  题字整行删(带位/侧栏即身份;Python TickMonitor/TradingWidget 无题同口径;
  盘口括号合约代码一并删——选中合约由下单面板代码格与行情带跳色行指认)。
- **不动面(用户裁定)**:K 线窗标题保留合约;运维窗维持浮动弹窗形态
  (O1 内嵌页方案否决);「帮助」菜单保留。
- **钉**:s23 行选中(委托换选 + 成交独立槽);s17 改单击直达;快照重基线。
- **0.2.1 续(动作归行,用户裁定)**:行内任何一格单击/双击都触发该行动作
  (`click_cell` 标签矩形叠感应,外观不变);行情带盘口四列带方向
  (`import_tick`:买1 价/量 → 卖,卖1 价/量 → 买,价格列顺带填价,其余列
  方向/价格保持默认)。r8 钉逐列口径。

## BD-UI-32(0.2.2 字号与独立窗轮,2026-10-19;用户三点裁定)


- **R1 全局字号上调**(`apply_ui_style` 新口):body/button 16、small 14、
  heading 22、monospace 15(egui 缺省 13/12/18/12)——用户实看「整体字偏小」,
  对照 Python 版;render_body 顶部施加,真实 App 与 kittest 同源(快照重基线)。
- **R2/R3 查询合约窗 + K线窗独立 OS 窗**(`show_viewport_immediate`):
  原生最小化/最大化/关闭钮(此前 egui::Window 只有关闭钮,且被物理关在
  主窗矩形内不能拖出——用户对照 Python 独立窗形态指出);可拖到屏幕任意
  位置、独立任务栏条目。OS 关闭钮 = close_requested → 状态位翻负 → 父窗
  下一拍不再 show → eframe 销毁窗。Embedded 回退(后端不支持多窗:
  kittest/WEB)= 主窗内弹窗同一本体,测试断言面零改动。
- **不动面**:运维窗/CTP 窗维持主窗内弹窗(用户只点了这两个窗)。
- **选型**:immediate 而非 deferred——回调可捕获 `&UiShared`(UiShared 非
  Clone,deferred 需 Arc 化会动 render_body 签名 = 无谓破坏性);StationApp
  1s 节流重绘节奏下 immediate 的父子联动重绘代价可忽略。
- **0.2.3 勘误(关不掉根因,用户实看指出):** egui 销毁判据 = 子 viewport
  本拍未 used——0.2.2 调用点未按状态位守卫,每拍 show = 每拍 used = OS 窗
  永不销毁(X 翻位后又被 show 回来,等效 CancelClose),且窗在主窗一开就
  自己出现。修复 = show 整段包进 `contracts_window_open()` 守卫。测试面
  教训:Embedded 回退掩盖 Immediate 真窗面(21 绿 ≠ 真窗面正确),s24 注入
  recording renderer 补钉;复审纪律补条:新机制的每个调用点必须按状态位辞行。

## BD-UI-33(0.2.4 四窗一型独立化,2026-10-19;用户裁定)


- **运维窗 + CTP 网关窗也转独立 OS 窗**(「四窗皆可脱离主窗」):与查询合约/
  K线同形态同守卫;Embedded 回退维持主窗内弹窗(kittest 断言面与快照零改动)。
- **合约表吃满窗口:** 撤 max_height(260) 内嵌时代残留;动作行(计数 +
  订阅钮)前置,ScrollArea auto_shrink(false) 吃满剩余窗高——放大窗口 =
  表格变高(用户「放大窗口意义在哪里」的直接回应)。
- **`show_independent_window` 助手(四窗一型):** 守卫模式内建(0.2.3 教训
  固化成调用方纪律:位关不 show = GC 销毁);Embedded/Immediate 两面的
  "被关"统一以返回值出表,消除四份 viewport 样板重复。

## BD-UI-34(0.2.5 表头固定 + 主题 + K线最大化/ESC,2026-10-19;用户裁定)


- **表头固定(`pinned_table` 五表全上):** 官方 DIY 模式(scroll_area.rs:412
  注释「painting a sticky header on top of it」)——单 grid 实例(头行+体行)
  保列对齐,ScrollArea 之后以本拍捕获的表头格矩形重画表头带(背景盖行 +
  Label 重画;矩形已含滚动平移 → 横向跟随、纵向盖顶、零拍滞后;Label 无
  交互 sense → 点击穿透)。两轮机制实锤:①vertical 嵌 horizontal 高度塌缩
  (s3 底行钉 462<500 抓到);②同 id 双 grid 各从空白重建 State,save 交替
  request_repaint = 重绘环(kittest max_steps 爆)——两案皆弃,留 overlay 案。
- **主题四套(金融深色默认/egui深/egui浅/Qt灰白):** 菜单钮一键循环;
  Theme 枚举 + AtomicU8 运行期态(零配置口径);apply_ui_style_for 统一施加
  (主窗/独立子窗/kittest 三处同源)。语义色跨主题恒定。回答用户「颜色是
  默认还是自配」:此前 = egui 默认深色骨架 + 自配语义色(非全默认)。
- **K线窗:** maximized 打开即最大化(s25 钉 builder.maximized=Some(true));
  ESC 快捷关闭(body 返回 true → close_kline 同链;s25 钉 Embedded 面注入
  Event::Key;Immediate 面键按子窗焦点路由)。
- **行情带外层 ScrollArea 不可撤:** resizable 面板吃满前提(BD-UI-27),
  本轮实撤一次即 s3 底行钉实测塌到 462——已恢复并注释钉死。

## BD-UI-34 返修(0.2.6,2026-10-19;用户实看三报,全有机制实锤)


- **竖线(资金右):** 覆盖带 ui.put 视口外表头格 → 布局 min_rect 撑宽 →
  Frame 描边矩形撑到 x=1222(= 346.9 + 16 列总宽)。二分实锤:关覆盖带线
  消失;空内容时线跟内容宽走(1007)。修 = painter.text/rect_filled 纯绘制
  (零布局副作用)+ clip 裁越界。教训:覆盖/装饰一律走 painter,不得用布局
  调用(put/label)画视口外内容。
- **表头缝:** 带高须 = 表头格高 + item_spacing.y(3px),否则滚动行从间距带
  露出。
- **空表缩格:** 空态不得提前 return——pinned_table 照调(空 body),表头与
  吃满滚动区在场。s26 钉三表空态表头同在场。
- **group_frame_plain 使用纪律(0.2.6 钉死):** Frame::show 描边矩形按内容
  min_rect 收缩(实锤:label 内容时 gw 宽缩到 112)——只配 auto_shrink(false)
  填充物(pinned_table)使用;非表格格(资金/日志)必须走带填充 ScrollArea 的
  group_frame,否则 Frame 收缩、右边框落行中段。

## BD-UI-35(0.2.8,2026-10-19;用户实看三报,全机制实锤)


- 行情带状态行「tick N 格 / 最后更新」= 用户裁定撤除(考证:从未删过,
  0.2.0 撤的是题字)。s8 反向钉防回归。
- 表头缝硬根因 = clip_rect_margin(默认 3.0)外扩裁剪;覆盖带必须按
  inner_rect ± margin 起画。s27 当时用「滚动快照 + 像素扫描」核,0.3.2 起换成
  裁剪矩形 + 绘制指令(同一判据,不同取数)。
- 空态提示一律在表头之下(grid body 首格),不得再置于 pinned_table 之前。
- 列头单源纪律扩散到全部四张格表(const HEADERS):本轮自审又抓到两处
  空态分支凭记忆写错——手写第二套表头 = 必错模式,单源是唯一防线。
- 快照回读串行化(SNAP_LOCK):本机 wgpu 并发回读会随机成组失败,测试闸
  对此环境敏感;新增快照测试必须走 snap() 不得裸调 harness.snapshot。
  **0.3.2 作废**:快照与像素回读整条撤除,这把锁也随之删除(见文末「0.3.2 口径变更」)。

## BD-UI-36(0.2.9,2026-10-19;纸面网关迁出界面包)


- 用户裁定:纸面网关 = 网关,应与 ek-ctp 同级独立成包(Python 蓝图 PaperAccount
  即与 CTP/Xt/Qmt 同级独立 app;SimNow 登不上时切换的本地模拟流)。
  曾议落位:留 ek-ui(否决:界面包藏网关,无头测不到)/并入 ek-ctp(否决:污染
  纯 CTP 协议面)→ 裁定 = 独立 crate ek-paper。
- 语义零迁移实锤:旧 sim.rs 与新 matcher+plugin 非注释代码行 449↔449,
  Compare-Object 仅 4 处 Arc 写法面差异。
- 工厂表归一:sim 登记项上移 ek-station 0.1.8 default_kinds(无头/有头同一张表);
  ek-ui ui_kinds/pick_kinds 撤自建 sim 项防双份(注册名冲突 = add_plugin Duplicate)。
- 教训钉(注册表耦合顺序):改工厂表必须【先发布被依赖方】——station 0.1.8
  未发布前 ek-ui 测试 UnknownKind("sim")(本地改动对 registry 依赖不可见,
  这正是禁 path 耦合的代价与回报)。

## BD-UI-38(0.2.10,2026-10-19;纸面网关整体退场)


- 用户裁定(2026-10-19):纸面账户功能整体撤除,界面不再有 sim——
  **ctp/SimNow 两套前置(12 小时/7×24,选前置用 ek-ctp scripts/simnow-fronts.ps1
  -Probe)已满足当前需求**。BD-UI-37(sim 对外面对齐 CTP 的改造方案)作废,
  未执行半成品整堆丢弃(ek-paper 工作区回滚到 0.1.1 已发布态)。
- 退场涟漪(三包):ek-station 0.1.12 撤 ek-paper 依赖 + 工厂表 sim 项
  (pipeline_headless 改 dm/oms/ctp 装配 + CTP 回报行直发注入,账本/行情/因子
  钉数值零变);ek-ui 0.2.10 撤 ek-paper 依赖 + 下拉/兜底/钮/测试全清;
  ek-paper 0.1.x 档案原样留存(不删仓库不发版不 yank——yank 反咬已发布老版)。
- 界面口径:接口下拉 ∪ 集改 {ctp};parse_gateway 空兜底 sim→ctp;运维窗
  「流控查询」钮撤(UiCmd::QueryRate 变体同步撤——公共 API 减项);
  demo 窗 = dm/oms/ui(网关经运维窗激活);demo_tour 纸面段整段删(8 步重编)。
- 测试口径:ui_sim.rs 整删(9 钉);ui_converter_chain 网关位改测试本地
  "ctp" 受理夹具(受理形制忠实 ek-ctp:SUBMITTING 单上总线 + ok 回执 +
  `{gw}.` 全号;成交按 OnOrderTrade/OnRtnTrade 同构行直发)——本钉主体
  一直是 oms 五段管线,与网关撮合无涉,换顶替零减钉;kittest 夹具标签
  sim→ctp(五张快照重基线:s3/s10/s16/s21/s27,钮撤除 + 标签改名像素变);
  71→62 钉(减的 9 = ui_sim)。
- 纪律钉:历史条目(BD-UI-36、CHANGELOG 0.1.8/0.1.9 sim 入表等)是史实,
  不改写;退场只记新条目。

## BD-UI-39(多账号面:账户面板取代网关窗,规划 docs/ek-multi-account-plan.md)


> 本条取代的旧口径(史实保留不改写):BD-UI-24 的 `连接 CTP` → 硬编码
> `ctp.configure` / `ctp.subscribe`;BD-UI-27/33 的「CTP 网关配置窗」单账号窗体;
> BD-UI-38 的接口下拉 ∪ {ctp} 与 `parse_gateway` 空兜底 `"ctp"`。

- **菜单「网关」→「账户」**:旧单账号网关窗退役,收纳为账户面板的添加/编辑表单
  (9 输入位 + 密码掩码 + 收录勾选原样沿用,`ctp_grid` 未改字段)。旧窗的死面
  (`ctp_window` 状态位、`MENU_CTP`、`BTN_CTP_CONNECT`)一并删除,不留"供旧测试引用"。
- **账户清册在界面侧**(`UiShared.accounts`):行 = 派生注册名 + broker + 账号 +
  在场位 + 元账号标记 + 表单留档。派生名唯一入口 `ek_ctp::derived_gateway_name`
  (界面不自己拼字符串);密码只在内存,本轮不持久化(规划 §10.2)。
- **添加时序钉(规划 §5.A)**:一个钮两条 cmd —— `Start{factory:"ctp", name:<派生名>}`
  先装配,`CtpConfigure{gateway:<派生名>, form}` 后注入凭据(凭据不进 svctl.start 通道)。
- **单源纪律在"添加"这一刻定源(规划 §5.C)**:无元账号且无在场账户 → 新账户自动成为
  **行情元账号**(md 前置保留;清掉它等于这个账户自己也永远收不到 tick);已有元账号或
  已有在场账户 → 按纯 TD 装配(md 前置清空)。订阅两个入口(下单面板代码回车 /
  查询合约窗勾选)一律经 `subscribe_target` 路由到元账号,未指定就明确报错不静默发。
- **元账号连接后锁定(用户裁定「账户连接后不允许切换」)**:任何账户已装配即拒切换;
  换源流程 = 撤挂单 → 移除 → 改配置 → 重连 → 重放订阅(订阅集 `note_subscriptions`
  登记,元账号新装配后自动重放)。
- **移除守卫**:该账户有活跃委托 = 不给移除钮(只给"先撤单才能移除"提示);有持仓 =
  可移除但警告"柜台侧不受影响,重加后由轮转查询重建视图";两步确认沿用 `confirm_step`。
- **网关名回落只剩一个阶梯**:`default_gateway()` = 上次手动选择 → 首个已装配 → 首个;
  一个账户都没有 = 禁用「委托」钮并给提示(旧"空 → 字面量 ctp"兜底退役,B4)。
  撤单三处(行内钮 / 全撤 / 行双击)一律随该单自己的 `gateway_name`。
- **诚实标注钉**:账户表状态列写「已装配 / 未装配」不写「已连接」——界面只能从
  `svctl.list` 看见实例在场,登录成败 ek-ctp 无状态口可查(开放项)。
  **(0.2.19 已接上:`{gw}.status` 落地,状态列改两段口径「已装配·<登录态>」;
  「不写已连接」这条口径不变,见文末 0.2.19 节。)**
- **测试口径**:s13 重写(添加账户 → 派生名 + 两条 cmd + 首个账户自动定源 +
  第二账户 md 清空不被抢源)、s17 菜单改「账户」、s18/s20 订阅路由到元账号、
  新增 s28(元账号锁定 + 挂单移除守卫 + 守卫解除);五张快照重基线
  (s3/s10/s16/s21/s27,菜单标签与 tick 列名「接口」→「行情源」像素变)。

## 0.2.19(接上网关状态口 + 『断开』钮;规划 docs/ek-ui-account-tabs-plan.md P3,2026-10-22)


事实源换成了 ek-ctp 0.9.0 的两个服务口(`{gw}.status` / `{gw}.disconnect`)。界面侧的六条口径:

- **D-UI-STATUS-1 状态列两段,不合并**:装配位(`svctl.list` 在场)与登录态(`{gw}.status`
  读数)是两件事,拼在一栏显示(`已装配·在线` / `·掉线` / `·登录失败` / `·查询断供` /
  `·已断开` / `·登录态未知`)但**不合成一个布尔**。0.2.18 的「已装配·登录态未知」保留为
  读数缺失时的显式档位 —— 缺口从「不可见」变成「本轮还没问到」,两者必须长得不一样。
- **D-UI-STATUS-2 只认 `logged_in`,不认 `handle_up`**:网关的「句柄已建 / registerFront 已发」
  在掉线时仍是真(ek-ctp DESIGN D-STATUS ①)。界面照抄它 = 替掉线的账户谎称在线(s48 读数二钉)。
  同理 **"从未回报过"不当"0 秒前回报过"用**:`td_last_account_ms = None` 是"没有这件事",
  不是"很久没做"。
- **D-UI-STATUS-3 阈值住在界面**:「查询断供」= 连续被拒达 `QUERY_REJECT_STREAK_LIMIT`(3)
  **或** 资金回报超 `ACCOUNT_STALE_SECS`(30 秒)。网关只给原料(计数与时刻),"几次/多久算断供"
  是界面口径 —— 它才知道自己多久刷一次、多久问一次。覆盖位 `set_status_thresholds` 已开,
  配置文件面还没定(规划 Q10 只说"进配置",ek-ui 现在零配置)。
- **D-UI-STATUS-4 轮询只问"已装配且在场"的行**:每 `STATUS_POLL_TICKS = 2` 拍问一次。
  实例被摘除后清册行还在,照问不误就是每次等满 2 秒超时、按账户把 eTimer 刷新循环拖住(c5 钉)。
  状态口在 ek-ctp 侧只读、不写日志,所以这条轮询不吃 eLog 环(512 行环保护不变)。
  **问不到就退回「未知」并写明原因**,上一轮的「在线」不得留档(s50)。
- **D-UI-DISCONNECT-1 断开 = 停会话,移除 = 停实例,两条口不共用**(规划裁定 1):
  『断开』发 `UiCmd::Disconnect` → `{gw}.disconnect`;『移除』发 `UiCmd::Remove` → `svctl.remove`。
  断开后装配态不变、徽标「已断开」、恢复入口仍是『连接』(插件侧走「已在场 → 重投凭据」。
  **同一道活跃委托守卫两者共用**:会话一停就撤不了单也不再回报,与『移除』要挡的是同一个
  观测黑洞(规划 §5.B),出口也同一个(『先撤 N 笔』)—— 这是执行自查补上的一条(s54)。
- **D-UI-STATUS-5 实例不在场即作废读数**:`sync_accounts_connected` 把行从「已装配」打回
  「未装配」时,登录态与细节一并清零;渲染侧只给已装配的行印状态细节(s55)。
  留着就是替一个已经不存在的会话说话。
- **纯行情账户看 MD 半边**:判据是"该行 td 前置留档是否为空"。TD 半边对它是全假的,
  照抄只会得到「未知」——那是假信息(s53)。
- **测试口径**:kittest 45 → 53 钉(s48 三档读数与旧读数不留档、s49 断供两判据与阈值覆盖、
  s50 问不到退回、s51 『断开』入列与徽标翻转、s52 未装配无『断开』钮、s53 纯行情看 MD、
  s54 断开共用活跃委托守卫、s55 摘除即作废读数);s37 口径翻转;总线级新增 c4/c5
  (真起一个 ek-ctp 实例:服务目录含 `.status`/`.disconnect`、ask 真拿到 `GatewayStatus`
  —— 读数里没有「问不到」就是两份 ek-ctp 没静默断的证据);s39/s40/s41 快照重基线。

## 0.2.20(三条开放口径落进界面 + 接 ek-ctp 0.9.1 分项;2026-10-22)


- **D-UI-F4-SCOPE 勾选的作用范围写死成一句话,两处同源**:勾选只管**这个网关自己**收录并上抛的
  合约;公用合约表 = 在场各网关的并集(规划 D3「合约与行情公用」的直接推论);改勾选须
  『连接/重试』才下发。为什么必须写在界面上:实连 S14b 里账户 B 改勾选「期货+期权」后公用表
  仍 852 条、期权 0 条,而界面没有任何地方区分「没生效」与「网关没查到」—— 让用户猜就是
  把口径缺陷转嫁给用户。**同一口径不得只写在一处**(面板底部 + 配置弹窗,s57 数到 2 处)。
- **D-UI-Q17 运维窗对账户清册实例只列不操作**:名字在清册里 → 那一格改写成「起停归『账户』面板
  (带撤单守卫)」,不给『移除』也不给『重注入』(后者对命名实例本来就是 unknown factory)。
  理由不是"整洁",是**守卫可绕过**:账户面板的『移除』带活跃委托闸 + 二次确认 + 持仓提示,
  运维窗那条是同一动作的无守卫副本,两条路并存等于守卫形同虚设。**清册外的 `ctp_*` 实例仍留
  这条出口**(手工起的 / 旧流程残留没有别的退路)—— 边界收口不等于把门焊死。实例仍列在表里:
  砍的是操作,不是运维可见性(s56)。
- **D-UI-LOG-WINDOW 日志窗自报是窗口不是历史**:溢出计数原来只有数字;现在同一行接上去处
  (「登录/掉线/查询被拒看状态列与状态细节,别回扫这里」)。依据是巡回 G1 的实连证据 ——
  90 秒没抓到「交易服务器登录成功」行而资金行下一拍就刷新,**登录其实成功,是 512 行环被
  状态推送流挤满**。取证的事实源自此是网关状态口(0.2.19 起),不是日志回扫(s59)。
- **D-UI-STATUS-6 分项点名但不另开一行**:ek-ctp 0.9.1 的 `td_instrument_rejects` 接进状态细节
  句尾(「查询连续被拒 3 次;合约查询被拒 2 次」),同一件事不重复点名,分项为 0 不加句子。
  总数与连续串**不分路**是对的(消费侧要的是「查询这条路连续被拒几次」),但合约一条没到手时
  下一笔单会被 ContractGate 以 `contract:unknown` 拒掉 —— 状态列只写「在线」就是假信息(s58)。
- **测试口径**:kittest 53 → 57 钉(s56 运维窗收口且工厂插件照旧、s57 口径两处同源、
  s58 分项三段、s59 日志窗口径);s39/s40/s41 快照重基线(面板底部多一行口径句);
  `.gitignore` 补 `**/snapshots/**/*.old.png`。

## 0.2.21(第三条断供判据:「从未到手」也要超时;2026-10-22,依赖不变)


- **D-UI-STATUS-7 「从未发生」不能只是"没有这句话",它自己也要有超时位**:0.2.19 立「`None` 不当 0 用」
  时,只把它用在"距最后一次回报多久"这一判据的**前置条件**上(`Some` 才谈超时)。实连 S15f 第一轮
  读数照出洞:F2 账户 `logged_in=true`、`td_query_rejects=0`、`td_last_account_ms=None` 持续 390 秒
  不变 —— 于是界面永远是「已装配·在线 + 资金回报尚未到手」。**徽标是绿的,查询却彻底停摆**,
  这就是 F2 在界面上的形状。
- **修法与边界**:登录着的分支第三条判据 = 距 `td_last_login_ok_ms` 超过同一个 `ACCOUNT_STALE_SECS`
  且资金时刻仍 `None` → `已装配·查询断供` + 「资金回报从未到手(登录已 Ns)」。刚登录几秒不算
  (轮转第一拍本就要 2~6 秒);真到手即回在线。**不新建第二个阈值**:"多久算等太久"在两条判据里
  必须是同一个数,否则同一件事有两种松紧。
- **为什么修在 ek-ui 而不是 ek-ctp**:判据原料(登录时刻 + 读数时刻)网关早就给了,缺的是消费侧
  怎么用 `None`。网关侧加"资金超时"位就是把界面口径往上游搬(D-UI-STATUS-3 的反面)。
- **测试口径**:kittest 57 → 58 钉(`s60` 三段:5 秒 = 在线带话 / 40 秒从未到手 = 断供带时长 / 到手即回在线)。

## BD-UI-40(0.2.26,2026-10-22;第五套主题「Qt深色」= Python 版黑色主题复刻)


- **依据是读出来的,不是印象**:Python 版 `enkeep_trade/enkeep_trade/trader/ui/qt.py:102`
  `qapp.setStyleSheet(qdarkstyle.load_stylesheet(qt_api="pyside6"))`;本机已装
  **QDarkStyle 3.2.3**(`.venv/Lib/site-packages/qdarkstyle`)。色板取自 `dark/palette.py` +
  `colorsystem.py` 原值,规格取自编译产物 `dark/darkstyle.qss`(2216 行实测:
  `border-radius: 4px` 出现 48 次且是全主题唯一圆角值;描边一律 1px;padding 以 2px 为主)。
- **主题从四套变五套**,菜单钮一键循环加一站:`Qt灰白 → Qt深色 → 金融深色`。
  **缺省仍是金融深色** —— 加主题不换缺省,现有 8 张快照一张没动(git 里只有 s42 是新增)。
- **`Theme as u8` 是 `set_theme`/`theme()` 之间的存储形式**,新变体必须留在枚举末尾、
  且 `theme()` 的 match 必须加 `4 =>` 臂。**漏臂是这条链上最阴的一种错**:编译器不报、
  `set_theme` 照存、读回来落进 `_ =>` 解回金融深色 —— 钮上文案换了而画面不换。挡它的是
  `r9c`(五个判别值逐个钉死 + 每个定点值往返一遍)。
- **映射口径(逐条对过 qss)**:
  - `window_fill = panel_fill = extreme_bg_color = #19232D`(Gray.B10)。qdarkstyle 所有容器
    同底、靠 1px `#455364` 描边区分 —— 三个字段同值不是偷懒,是照抄。
  - 按钮三态 = `#455364 → hover #54687A → pressed/checked #60798B`,且 qss 里
    `QPushButton { border: none }` → `bg_stroke` 与 `bg_fill` 取同色(画出来就是无边)。
  - `expansion` 一律 0.0:qss 的 hover 只换色不涨块,egui 缺省 hover/active 涨 1px。
  - `fg_stroke` 宽度一律 1.0:Qt 不在 hover/pressed 加重文字,egui 缺省 1.5/2.0。
  - `selection.bg_fill = #346792`(Blue.B40):qss 里 `QWidget::item:selected` 与
    `QLineEdit:selected` 同值。**`selection.stroke.color` 被 egui 当选中项文字色用**
    (style.rs:374)→ 取 TEXT1 `#DFE1E2`;照抄 qss 的 focus 边框 `#1A72BB` 会让选中 tab 文字变蓝。
  - `weak_text_color = Some(#ACB1B6)`(Gray.B90)、`hyperlink_color = #259AE9`(Blue.B70)、
    `faint_bg_color = #293544`(Gray.B20)、圆角全态 `CornerRadius::same(4)`。
  - `interact_cursor = Some(PointingHand)`:Qt 按钮是手型光标,egui 缺省是浏览器口径。
- **已知偏离(做不到 / 决定不做,逐条给理由)**:
  1. **选中 tab 那条 3px `#259AE9` 下划线**(qdarkstyle 的签名样式)—— egui `Visuals` 没有这个
     概念,`selectable_label` 只能整块填充。做它要改 `render.rs` 里 8 处手绘,不在本轮范围。
     本轮取「整块强调色」代替「灰蓝底 + 亮蓝下划线」。
  2. **字号不动**:Python 版是全局 `QFont.pointSize(11)`(96dpi ≈14.7px),我们 body 16px。
     字号在 `apply_ui_style_for` 里是跨主题的全局口径、不是主题属性,改它牵动其余四套与 8 张快照。
  3. **禁用文本色 `#788D9C` 做不到**:egui 只调 `disabled_alpha`,不能指定具体灰蓝 → 保持 0.5
     (那档亮度约 0.55,视觉近似)。
  4. **菜单底色 `#37414F` 做不到**:egui 0.33 的 menu/popup 背景跟 `window_fill`,无独立字段。
  5. **不移植图标资源**(qss 的 `rc/*.png` 箭头、树形 branch、窗口关闭钮、14×14 复选框图标)
     —— egui 自绘,只映射颜色不映射形状。
  6. QGroupBox 的 1px 描边 + 粗体标题、表格 gridline:egui 无对应物,靠
     `noninteractive.bg_stroke = #455364` 近似。
- **有意不设的字段(防「多余」)**:`text_edit_bg_color`(缺省跟随 `extreme_bg_color`,同值设两遍
  是重复)、`code_bg_color`(本 UI 的 `ui.monospace` 只换字体不带背景)、`warn/error_fg_color`
  (语义色在渲染侧直绘、跨主题恒定 —— D-UI-34 那条口径不变)、slider/progress 相关(本 UI
  0 处 slider、0 处进度条)。
- **测试口径**:ui_state 13 → 16 钉(`r9` 循环改五站 + `r9b` 色值钉 + `r9c` 编码钉 +
  `r9d` 反证钉),kittest 58 → 60 钉(`s42_qdark_theme_full_panel` 快照 + `s61` **真实手势**钉:
  不碰 `set_theme`,只点菜单钮,连点四下从缺省走到 Qt深色,每档都判「`shared.theme()` 换到位 +
  钮文案跟着换 + 画面底色真的换」,再点一下判环闭合回缺省;`s42` 夹具与 `s21` 同源 ——
  同一副内容换主题,快照差异只剩主题本身)。全绿 = 104 钉。
- **快照不只用眼睛看**:`target/theme-pixels.ps1` 把 s42 的 110 万像素数成直方图 ——
  面板底 `#19232D` 占 **93.17%**、`#455364` 2.09%、`#293544` 2.17%、正文 `#DFE1E2` 2125 px、
  选中块 `#346792` 748 px;反证两条同样硬:egui 原生深色底 `#1B1B1B` **0 px**、
  金融深色底 `#18202C` **0 px**(出现即说明主题没切过去)。`#54687A`/`#60798B`/`#259AE9`
  在静态快照里 0 px 是**预期**(静态图里没有 hover/pressed 控件、界面里没有超链接),
  那三支只在 `r9b` 的 Visuals 层钉住。
- **实连巡回取证(真窗口,不是 kittest)**:`examples/opening_multi_tour.rs` 加了一个
  `EK_TOUR_THEME` 开关(`qdark | finance | egui-dark | egui-light | qt-classic`,缺省不动;
  `examples/` 不在 `cargo package` 清单里 → 不进发布包、不动 crate API)。2026-10-21 17:3x
  用 7×24 前置(`EK_CTP_TD/MD` 指到 `EK_CTP_724_TD/MD`)跑一趟 `EK_TOUR_THEME=qdark`:
  控制台首行「【开盘演示】主题 = Qt深色」;元账号 `ctp_9999_278153` 登录 = true、纯 TD
  `ctp_9999_220380` 登录 = false(该账号在 7×24 前置未注册 —— 环境侧,与本轮改动无关);
  交易状态推送流 129 行。窗口截图 `target/gui-qdark.png`(1416×939)同样过像素直方图:
  `#19232D` 占 **83.96%**(余下主要是 OS 标题栏与边框)、`#293544` 3.65%、`#455364` 1.82%、
  正文 `#DFE1E2` 2111 px、选中 tab `#346792` 707 px,`#1B1B1B` 与 `#18202C` 仍是 0。
  截图里菜单钮读作「主题:Qt深色」,委托/成交两表是柜台真数据(6 笔委托 / 2 笔成交),
  语义色没漂(LONG 红 / SHORT 绿)。

## BD-UI-41(0.2.27,2026-10-22;对照 Python 版逐项核出来的界面面:表头带 / 网格线 / 日志表格化 / 页签头与下划线)


起因是用户实看 ek-ui 真窗口后点出的五处,并要求「去 Python 版本再看,逐个对比,肯定还有别的细节」。
核对方法:两边都读代码,不凭印象 —— Python 侧读 `enkeep_trade/enkeep_trade/trader/ui/mainwindow.py`
(布局)+ `widget.py`(表格与监控控件)+ 已装 qdarkstyle 3.2.3 的 `dark/darkstyle.qss`(2216 行,样式)。

### 逐条核对结果(12 条,两边都有行号)


| # | 项目 | Python 侧事实(行号) | 我们改前 | 处置 |
|---|---|---|---|---|
| 1 | 表头带底色 | qss:1946-1970 `QHeaderView::section { background #455364; color #DFE1E2; font-size 13px }` | 覆盖带填 `panel_fill` = 表体同色 → 等于没画 | **补**(本轮核心) |
| 2 | 表格外框 | qss:514-519 / 1875-1884 `border: 1px solid #455364` | 已有(`Frame::group` 描边 = `bg_stroke` `#455364`) | 不动 ✓ |
| 3 | 网格线 | 交易表 `widget.py:367 setShowGrid(False)`(注释「隐藏网格线」);**只有**合约表 `2423` / `2621` 没关 | 全无网格 | **只给合约表补** |
| 4 | 斑马纹 | `widget.py:359 setAlternatingRowColors(True)`,但 qss:515/1879 `alternate-background-color` = `#19232D` = 表体底 → 看不见 | 我们 `striped(true)` 硬开,奇行 `#293544`(实连截图里占 10.72%) | **收掉**,改跟 `visuals.striped` |
| 5 | 日志 | `LogMonitor`(`widget.py:1167-1180`)= 带「时间/信息/接口」表头的 QTableWidget,跟 Dock 伸展 | monospace 行 + `max_height(180.0)` 硬顶 | **换成 `pinned_table`** |
| 6 | 每格都有 tab 头 | 四个区全是 QTabWidget(`mainwindow.py:106/126/168/184`),单 tab 的「成交」「持仓」也有头 | 成交/持仓只是 `ui.label` | **补单 tab** |
| 7 | 选中 tab 下划线 | qss:1592 `:selected { background #54687A; border-bottom: 3px solid #259AE9 }` | 无(0.2.26 记为「做不到」) | **补** —— 那条已知偏离作废 |
| 8 | 未选中 tab 头底 | qss:1579 `QTabBar::tab:top { background #455364 }` + 1559 `!selected { border-bottom 2px }` | 无 | **补** |
| 9 | 服务目录表头 | 合约/账户类表都有 `setHorizontalHeaderLabels` | `svc_grid` 六列纯数据无头 | **补**(用户点名) |
| 10 | 表头字号方向 | 表头 13px < 正文 ≈14.7px(`QFont pointSize 11`,`qt.py:106-107`) | 表头 = 正文 = 16px(方向反了) | **表头改 Small 14** |
| 11 | 单元格 padding | `widget.py:369 item { padding: 4px 8px }` | `item_spacing (8,4)` | 相近,**不动** |
| 12 | 列集合 | 委托 16 列 / 行情按所分表 / 成交 12 列 | 委托 17(多「操作」)、行情 14 用 tab 分所 + 「今开」、成交号放第一列 | **有意不动**(功能差异,不是漏) |

### 落法与理由


- **`Chrome` 取色层**:qdarkstyle 有「表头带」和「选中 tab 下划线」两个概念,egui `Visuals`
  里没有对应字段(它只有 `selection.bg_fill` 一根)。所以这层只能在渲染侧直绘。
  取色**不新造颜色**,全部从各主题已声明的字段派生:`line = noninteractive.bg_stroke.color`、
  `header_text = noninteractive.fg_stroke.color`、`tab_underline = hyperlink_color`、
  `tab_fill`(Qt深色)= `hovered.bg_fill`。Qt深色那一列取出来正是 qss 原值
  (`#455364` / `#DFE1E2` / `#54687A` / `#259AE9`),其余四套取自己的色板 —— 五套一起改,
  因为只改 Qt深色等于没改用户日常看的那一套。
- **为什么文字必须重画**:epaint 0.33 没有 z-order(全 crate 搜过:无 `ZOrder` / `z_order` /
  `Shape::bg_fill`),形状按插入顺序画 → 后画的带底会盖住先画的表头文字。所以
  「盖底 → 画线 → 重画文字」是固定套路,`pinned_table` 覆盖带、`header_band`、`tab_label` 三处同一个理。
- **`header_band` 而不是 `pinned_table`**:账户清单 / 运维插件表 / 服务目录在**自动尺寸浮动窗**里。
  0.2.17 实锤(s39):`pinned_table` = `auto_shrink(false)` 吃满整块可用高 + 覆盖带,
  在这种窗里凑不出高度 → 表行与其后的口径句互相压字。这三张表行数个位数,不需要固定表头滚动面,
  只要那根带。
- **网格线的画法**:`pinned_table_grid` 从 grid 实测的**列边界矩形**(表头格)推列线 x
  (`h.max.x + item_spacing.x * 0.5`),从内容矩形 + 行数等分推行线 y。前提:每格都是 label
  → 行高一致(合约表正是如此)。所以网格线只给合约表,交易表不给(那边关了网格)。
- **日志时间戳口径**:`LogRing::push` 入环打 `chrono::Utc::now().with_timezone(+08)` 的
  `%H:%M:%S`,与 `fmt_dt` 同一口径 —— `beijing_tz` 因此从 render 提到 state,一份实现两处用。
  本机不是 +08 时口径错 = 日志与成交表差几小时,交易员对不上账(钉:
  `log_entries_stamp_beijing_wall_clock`)。
- **`snapshot()` 门面不动**:`LogRing` 内部从 `VecDeque<String>` 换成 `VecDeque<LogEntry>`,
  但 `snapshot()` 仍返回正文串 —— 30 处既有调用点(测试 / 巡回取证 / demo)一行没改。
  渲染侧另开 `entries()` / `logs_entries()`。两个口各有真实消费者,不是重复。

### 已知偏离(记死,不藏)


1. **日志只有两列**(时间/信息),Python 有第三列「接口」:我们的 `log_line` 调用点不带网关,
   正文自带 `[ops]` / `[订阅]` 这类前缀。硬造一列空列不如不造。
2. **日志不自动跟到最后一行**(`stick_to_bottom` 撤除):Python 的 QTableWidget 也不自动滚到底。
3. **斑马纹**:Python 的意图是开(`setAlternatingRowColors(True)`),但 qss 让它不可见。
   我们跟「不可见」这一结果。**这一条我先前给过相反判断**(一度主张保留斑马帮读 17 列宽表),
   最终按「与 Python 一致」定;若日后要开,应开在主题层(`visuals.striped`)而不是表里硬开。
4. **字号**:全局 Body 16 / Small 14 vs Python 全局 `pointSize 11` ≈14.7px。字号是跨主题全局口径,
   本轮只把「表头比正文小」这个**方向**修对,没有动全局字号。
5. **列集合**(表 12 行):功能差异不是漏。

### 审计(遗漏 / 错误 / 重复 / 多余 / 逻辑)


- **改了什么、删了什么,逐条可查**:删除的只有 `group_frame`(四格全走 `group_frame_plain` 后无使用者)
  和 render 侧那份 `beijing_tz`(搬到 state,render 改 import)。**没有删任何功能面**:
  日志钮、溢出提示句、清空钮、`snapshot()` 门面、`log_grid` 之外的所有表容器都在。
- **发现并修掉的自己的错**(都是本轮内自查抓到,不是门禁抓的):
  ① 合约表两处调用点我先把**空态分支**换成了 `pinned_table_grid(…, rows.len(), …)`(空表画网格),
  下一眼读回时看到并改回;② `Chrome` 字段用了裸 `Color32` —— `use egui::Color32` 是
  `theme_visuals` 函数内的局部 import,结构体在函数外看不见,编译报 E0425;
  ③ `Painter::line` 在 0.33 收 `Vec<Pos2>` 不收元组 → 改 `vline` / `hline`;
  ④ `Grid::show` 返回 `InnerResponse`,`gr.rect` 不存在 → `gr.response.rect`;
  ⑤ 新测试里「时间」「服务」「实例」都不是唯一标签(行情带也有「时间」列、运维窗也有「服务」标题、
  服务目录有三对「实例」头)→ 改 `query_all_by_label` 数个数,顺带把断言变强。
- **重复**:一度想给日志表加「时间列 + 正文列 + 接口列」三列,与「调用点不带网关」冲突 → 砍成两列。
  `snap` / `snap_budget` 两个快照入口是**必要**的两条:一条 0 容差,一条带墙钟预算,共用同一把锁。
- **多余**:`group_frame` 删掉;`Chrome` 里一度准备放 `tab_head_stroke` 与 `tab_underline_width`
  两个字段,前者与 `line` 同值、后者只有 2/3 两个常量 → 都不进结构体,写在使用处。
- **逻辑问题(本轮最容易再犯的一种)**:带墙钟时间列的快照天然不确定。第一反应是「调容差」,
  真正的处理是把**结构面**钉在 AccessKit 标签上(确定性),把**像素图**降级成带预算的辅助证据,
  并在 `snap_budget` 的注释里写清为什么这三张要预算。
- **没动的**(有意):`snapshot_lock()` 旧调用形式(9 处)只在新钉里换成 `snap_guard` ——
  改全部旧站点属于另一轮清理,本轮不夹带。

### 证据


- 门禁:`scripts/gate.ps1` 全绿(fmt / test 111 钉 / clippy `-D warnings` 0 / doc 0 / check-package strict)。
- 快照:9 张重生成 + 新增 `s63_logs_table_headers`、`s65_ops_service_headers`;
  肉眼核过 `s42`(Qt深色)与 `s21`(金融深色):六张表都有表头带,委托/未成交、资金/日志、
  行情带交易所、成交、持仓五处都有 tab 头,选中 tab 有下划线,画面无斑马。
- 像素直方图(`target/theme-pixels.ps1`,本轮加两条硬检查):`#259AE9 ≥ 1`(选中 tab 下划线,
  改前 0)与 `#54687A ≥ 200`(选中 tab 底,改前只在 hover 时出现 = 0)。
- 真窗口:`EK_TOUR_THEME=qdark` 实连 7×24 前置一趟,截图过同一套像素检查(见本轮报告)。

## BD-UI-42(0.2.28,2026-10-22;表头带与网格线的几何根因:`Response::rect` 是控件矩形,不是列框)


用户实看:「运维那个表格表头还是有问题的,表头背景没有按这个表格整行来,最后一列感觉好像只有
文字有背景,其他没有文字,导致好像断开一样」+「资金账户那个表格怎么又有问题了,怎么老是有不同的
表格有各种问题」。这次不逐张表打补丁,而是把「带和线按什么算宽度」这一条查到底。

### 十张表全核一遍(先列清楚有哪十张)


| 表 | 容器 | 带从哪来 | 本轮结论 |
|---|---|---|---|
| 行情带 `tick_grid` | `pinned_table` | ScrollArea `inner_rect` ± `clip_rect_margin` | 本来就对 ✓ |
| 委托 `order_grid` / 未成交 `active_grid` | `pinned_table` | 同上 | 对 ✓ |
| 成交 `trade_grid` | `pinned_table` | 同上 | 对 ✓ |
| 持仓 `pos_grid` | `pinned_table` | 同上 | 对 ✓ |
| 日志 `log_grid` | `pinned_table` | 同上 | 对 ✓ |
| 查询合约 `contract_grid` | `pinned_table_grid` | 同上 | 带对;**列线错** → 修 |
| 资金格 `acct_grid` | `header_band` | 表头文字矩形并集 | **错** → 修 |
| 运维插件表 `plug_grid` | `header_band` | 同上 | **错**(用户点名的那张)→ 修 |
| 服务目录 `svc_grid` | `header_band` | 同上 | **错** → 修 |
| 账户清单 `account_grid` | `header_band` | 同上 | **错** → 修 |

(下单面板 `order_form_grid` 是表单不是表,无表头行,不在这条上。)

### 根因只有一条


`egui::Response::rect` 是**控件自己的**矩形,不是列框。Grid 的路径:
`GridLayout::next_cell(cursor, child_size)` 给出列框 = `child_size.max(prev_col_width, prev_row_height)`
(grid.rs:180-185),随后 `align_size_within_rect` 按 `LEFT_CENTER` 对齐,**返回子控件尺寸**
(grid.rs:188-193)。所以一个表头 label 的 `rect.max.x` = 表头文字的右端,不是列的右边界。

0.2.27 我在列线那行写的注释是「取表头格的列边界(列宽由 grid 量出来,唯一权威源)」——
**这句话是错的**,而错的地方正是新代码唯一依赖的那个量。本轮更正注释也更正算法。

两个后果:

1. `header_band` 的带 = 表头文字矩形的并集 → 带只到最后一列表头文字的右端。运维窗『操作』列
   宽 = 两个按钮(~200px),表头「操作」只有 ~40px → 列里 160px 没有带;服务目录最后一列
   「实例」为空时同理。用户看到的「最后一列只有文字有背景 / 断开一截」就是这个。
2. `pinned_table_grid` 的列线 = 本列表头文字右边 + 半间距 → 表头比数据窄时(「名称」2 字 vs
   「螺纹钢2605」)列线画在列里面,不是列之间。

### 两条改法


- **带铺整行**:`header_band` 的横向范围改 `ui.max_rect()`(这一格 / 这一窗的可用宽)。
  与 `pinned_table` 那条带同一口径(那条铺 ScrollArea `inner_rect` = 整格宽),也与 Python 一致
  (`QHeaderView` 铺满整个表视口,最后一节 stretch)。
- **列线取下一列的左边界**:每列的格都左对齐在列起点,所以「下一列表头/数据格的 `min.x`」
  就是列边界,往回让半个 `item_spacing.x` 即列线。这个量不依赖表头文字宽度。

### 新钉与「判据到底抓不抓得住」


- `s69_ops_window_header_band_covers_the_row`:像素判据 = 带的最宽连续段 ≥ 体表最宽一行的
  文字跨度。两个坑都踩过并写进注释:① 带里有表头文字把连续段打断,要取带**底**几行;
  ② 格子的外框线也是带色(1px 横跨整格),单看最长段会把框线当成带(s70 第一版就是这么
  「通过」的)→ 加「带必须有厚度」(上下各 3 行的带色像素数不低于本行一半)。
- **判据有效性是实测过的**:把 `header_band` 换回旧算法重跑 → s69 直接红,带 **375 px**
  (修后 720 px)。不是写完就信的钉。
- **诚实的一条**:s70 的「带 ≥ 体表」断言在夹具里新旧都过 —— 资金表只有五列、列宽由数据
  撑开、内容几乎铺满整格,夹具里带和体表本来就差不多宽。用户在真窗口看到的资金带短一截
  (最大化时带 336 px / 整格 450 px)由 s70 的**快照**抓(旧算法下差 627 px)。
  教训:「带必须盖住体表」和「带必须铺满整格」是两条不同的判据,夹具只抓得住前者里
  内容真的比格子窄的那些表。
- `s71_contract_table_gridlines_land_on_column_bounds`:合约表是全仓唯一开网格线的表,
  原来没有任何带真行的快照核它。补一张(三行真数据,含「精对苯二甲酸709」这种名字明显
  比表头宽的列),肉眼核过列线落在列与列之间。

### 顺带修的一处潜在随机红


wgpu 后端钉(Windows 缺省 Vulkan,避开本机那张 "GameViewer Virtual Display Adapter" 的坏回读)
原来埋在 `snap()` 里。只用 `harness.render()` 的像素钉(s67/s68)靠"同二进制里恰好有别的
测试先跑过 snap"才拿到 Vulkan —— 测试线程顺序不保证 = 随机红。本轮 s69/s70 单独跑就直接
炸在 wgpu 里,证明这条不是假想。提出为 `ensure_wgpu_backend()`,`snap` / `snap_budget` /
四个 render 钉各自先钉(OnceLock 保证只写一次且先于任何 wgpu 实例)。

### 审计


- 本轮改动面:`header_band` 的横向范围、`pinned_table_grid` 的列线 x、测试侧
  `ensure_wgpu_backend` 提取 + 三个新钉。**没有删任何功能面**,没有动主题、没有动依赖。
- 0.2.27 的 CHANGELOG/DESIGN 里那句「列宽由 grid 量出来」保留在原条目里不回改
  (历史条目不改写),但代码注释已更正,并在本节写明它当时是错的。
- 遗留(有意不动):带铺 `ui.max_rect()` 后,浮动窗里两张宽度不同的表(插件表 / 服务目录)
  带都铺满窗宽 —— 与 Python「表头铺满视口」一致,但如果日后要让窗内两张表各自贴合自己的
  列宽,应改成传 grid 的 `response.rect`。本轮按 Python 口径统一,不混两种。


## 0.2.29 — Wave A:数据监控表换 `egui_extras::TableBuilder`(BD-UI-43 / BD-UI-44)


### 为什么换(不是"换个库好看一点")


交接报告 §4 实拍八条毛病里,**2 列宽未按内容 / 3 数字不成列 / 5 成交表被右缘切掉且无横向滚动
提示 / 6 买5卖5 贴左缘 / 8 行线切进行里** —— 五条的根因是同一个:**表是我们自己拼的**。
`Grid` 没有"表头"概念(表头只是第一行),固定表头只能靠 painter 覆盖层;列宽由 Grid 在
**首帧**按当时可用宽量一次,之后内容变宽不回头;数字列没有对齐语义;行线是我们按 `row_h`
手算的、与真实行矩形无关 → 必然切进行里(0.2.29 第 1 步的 T1 钉在旧代码上实测:13 根线落在
y=423/444/…/674,文字中心在 424/445/466 → 判据红,证明判据抓得住这个缺陷)。

换 `egui_extras::TableBuilder`(0.33.3,与 egui 同版,emilk 官方维护)一次解决四条:
列宽按内容(两趟 sizing,`max_used_widths` 跨帧保留)、表头天然在体表滚动区之外
(`Table::body` 自己开 `ScrollArea::new([false, vscroll])`,table.rs:746 → **固定表头是库的行为**,
不是我们的覆盖层)、`striped` 跟 `visuals`(table.rs:482)、每格矩形由库给
(`TableRow::col` 返回 `(Rect, Response)`)。

### 换表过程中撞到的三条 egui 事实(写下来免得下次再撞)


1. **一次点击只发给一个 `Response`。** `context.rs:1384`
   `let clicked = Some(id) == viewport.interact_widgets.clicked;` —— 整个 viewport 只有一个
   `Flags::CLICKED`;AccessKit 那条路(`context.rs:1344-1350`)也只给被点的控件。于是
   「整格可点」(`TableBuilder::sense`)与「格里的钮 / 勾选框」不会同时拿到 `clicked`。
   换表第一轮直接把 5 个钉打红(s8 单击带入代码 / s11 双击撤单 / s23 行首格选中 / s18 / s29)。
   修法:`tcell` 带回**两个**响应(格响应 + 格内容自己的响应),调用点把两者 OR 起来
   (`CellOutcome` 把 `Response` / `()` / `InnerResponse<R>` 三种格返回值统一掉)。
2. **`Column::auto()` 不会为了"挤得下"而缩列。** 12 列成交表在 ~450 px 的格子里列总宽 ~800 px,
   库不缩(实测表头节点右缘到 x=1608,画布只有 1280)→ 溢出是真的,必须有一层横向 ScrollArea
   才谈得上"最后一列可达"。
3. **egui 的滚动条缺省是看不见的。** `scroll_area.rs:1418-1425`:把手色 =
   `scroll.foreground_color ? visuals.fg_stroke.color : visuals.bg_fill`,缺省
   `foreground_color = false` → 把手色 = 体表色,**条画了但和体表同色**。实拍两张
   (`tests/snapshots/s21_full_tables.png` 与 `target/chk-0229-frame.png`)都确认"列被右缘切掉、
   但没有任何横向滚动提示" = 毛病 5 只治了一半。修法:`pinned_table_impl` 里把
   `spacing.scroll` 显式写死 `floating=false` / `foreground_color=true` / `bar_width=10.0`
   (Qt 口径:滚动条占位、高对比把手)。改完 s21 里三张表的横向条肉眼可见。

### 边界:哪些表**不**换(写死,免得日后当成"没换干净")


- **换**(固定表头 + 会溢出 + 有行交互):行情 / 委托 / 成交 / 持仓 / 日志 / 查询合约 / **资金**。
  资金表原来在"浮动窗短表"那一类里,实测它和日志表共用同一块 tab 容器(不是浮动窗)→ 并入。
- **不换**(自动尺寸浮动窗里的短表,0.2.17 实锤 s39):运维插件表 / 服务目录 / 账户清单 /
  下单表单(`order_form_grid` 是表单不是表)。这类用 `Grid` + `header_band`。
  `pinned_table` = `auto_shrink(false)` 吃满整块可用高,在自动尺寸浮动窗里凑不出正确高度 →
  表行与其后的口径句互相压字。
- 列分隔线 + 可拖列宽只给合约表(`GridLines::Grid` → `resizable(true)`),照 Python
  `setShowGrid(False)` + 合约窗可拖列的口径;其余表只有行线。

### T7 行数上限实测(本机,同一夹具,5 次样本里的最优单帧建构)


| 行数 | dev(`cargo test`) | release(`cargo test --release`) |
|---|---|---|
| 50 | 11.98 → 29.4 → 12.2 ms | 1.32 → 2.16 ms |
| 200 | 42.80 → 105.2 ms | 4.96 → 8.33 ms |
| 1000 | 208.14 → 512.1 → 214.7 ms | 26.44 → **47.60 ms** |

判据「1000 行单帧 < 60 ms」说的是**实窗 = release 构建** → **成立**(两轮 26.44 / 47.60 ms),
不换 egui_table;退路(egui_table 0.7.0 虚拟滚动)保留在方案 §8.3,不启用。

**但这条不能当墙钟钉 —— 本轮实测逼出来的结论。** 同一 dev 判据在同一台机器上摆动 2–4 倍
(50 行 11.98 / 29.4 / 12.2 ms;1000 行 208 / 512 / 215 ms)。原因不是表格层,是夹具环境:
`cargo test` 里 75 个测试并发跑,快照钉那批在做 wgpu 渲染,墙钟样本被抢;而快照钉共享
SNAP_LOCK,一个墙钟红还会连带一片 `PoisonError`。我先按「规模比 ≈ 成本比」改过一版
(线性期望 4 / 5,上限 8),门禁里照样红了一次(实测比值 8.6,而同一轮 1000 行只用 215 ms
—— 比值被 50 行那档的小样本噪声放大)。**结论:这台机器上墙钟阈值只能当手工实测记录,
不能当 CI 钉。** `t7_…` 最终钉的是两件事:① 每 n 行都被真实建构出来(`query_all_by_label`
计数 = n,确定性、无噪声 —— 这才是「TableBuilder 没有虚拟滚动」的真正退路警报);
② 一条数量级上限(dev 3000 / release 300 ms)只抓病态爆炸。性能结论靠手工实测记录在这里,
要重测就 `cargo test --test ui_kittest t7_table -- --nocapture` 单独跑。

### 本轮新钉(118 + 4 − 1 = 121 钉,15 张快照)


`t2_column_width_follows_content`(同一张合约表喂短名/长名,下一列表头左边界必须右移 > 20 px,
且长名文字不得越过那条列线)、`t3_numeric_columns_are_right_aligned`(成交表价格列 + 行情带盘口(买1价 / 卖1价)每列三行数字右边界
差 ≤ 1 px,判据走 AccessKit 节点矩形不靠像素)、`t4_trades_table_scrolls_horizontally`
(12 列 + 长名称 → 滚一下首列表头必须横移 > 100 px、y 不动 = 固定表头不回归、滚动后「接口」
列右缘必须落进可视区)、`t5_row_backgrounds_do_not_alternate_under_qdark`(Qt深色相邻行底色
相同且 = 主题体表色 #19232D,证明斑马没被硬开、底色没硬编码)、`t7_table_builder_row_count_benchmark`
(见上一节:钉「每行都被建构」+ 数量级上限)、`t8_book_frame_price_and_volume_columns_are_aligned`
(下单侧五档盘口十行:价列与量列右边界差 ≤ 1 px、档位标签同一列;判据有效性实证 = stash 掉
代码改动后红在「价格格必须全被抓到」)。

顺带记一条 harness 事实:**滚轮事件只受理"指针在体表/空白处"的位置**,悬停在表头格上单发一次
滚轮不动(实测 x 不变);连发两次(真实用户滚轮天然是多拍)才受理。判据按这个形状写。

### 尾巴:五档盘口(BD-UI-45)—— 毛病 6 的本体在下单侧,不在行情表


上一轮审计我把「毛病 6 有判据了」记在行情表的买1价/卖1价列上,**位置错了**:用户实拍的
「买5/卖5 不对齐贴左缘」是下单侧的常驻五档盘口 `render_book_frame`。它原来是一支文本
`ui.label(format!("卖{i}  {:.1}  {:.0}", p, v))` —— 价与量靠**空格**对齐,位数一变(101.5 →
12345.5)整块错位。换成 `TableBuilder` 三列(档 / 价 / 量),价与量右对齐。

三条边界写在这里:
- **不用 `pinned_table`**:下单侧是窄 `SidePanel` 且外层已有 `ScrollArea`,`pinned_table` 的
  `auto_shrink([false, false])` 会吃满整块可用高(0.2.17 s39 那条教训的同型),这里用缺省
  `auto_shrink([true, true])` 贴合内容。
- **不加表头**:Python 侧没有五档盘口这个块(`widget.py` 只有行情表的买1价/买1量/卖1价/卖1量
  四列 + 「点击买列填充买1价」联动),这是我们自加的常驻盘口,保持无表头。
- **买/卖色仍是硬编码**,集中成 `BOOK_ASK_COLOR` / `BOOK_BID_COLOR` 两支常量,Wave B 令牌层接管。

`t8` 的判据形状值得记:同一个 tick 同时喂行情带表格与下单侧盘口 → 同名标签两处。判据按
**夹具里的 y 分区**只取盘口(`rect.min.y > 500.0`),夹具里行情带在顶部(< 300 px)、下单表单
字段在 500 px 以上才到盘口。
---

## 0.2.30 — Wave B:设计令牌 + 只留一套主题(BD-UI-46 / BD-UI-47)


**BD-UI-46(颜色 / 字号 / 间距只有一个来源)**:新增 `src/tokens.rs`。`qdark` 支是 qdarkstyle 3.2.3
qss 的原值(BG1 `#19232D` / BG2 `#293544` / BG4 `#455364` / BG5 `#54687A` / BG6 `#60798B` /
TEXT1 `#DFE1E2` / TEXT3 `#ACB1B6` / ACCENT2 `#346792` / ACCENT4 `#259AE9`);`semantic` 支是**用途**色
(涨 `#E05050` / 跌 `#2E9E2E` / 警 / 错 / 成 / 信 / 弱)。口径:**涨跌色与对错色不许混同**(U3 钉),
**源码里除 `tokens.rs` 外不得再出现 `Color32::from_rgb`**(U2 机械钉扫源码)。
egui-charts 0.2.0 的 `ChartConfig::default()` 读它自己的 RON 设计令牌(青涨红跌),**不受 egui
`Visuals` 影响** → K 线必须 `Chart::with_config` 显式覆盖,否则「红涨绿跌」在图上反着画。

**BD-UI-47(只有一套长相)**:用户裁定只留 Qt深色。`Theme` 枚举 / `theme()/set_theme()/cycle_theme()` /
`apply_ui_style_for` / 菜单主题钮 / `EK_TOUR_THEME` 一并删除 —— 公开面真删,不是留着不用。
配套新增 `src/ui_ext.rs` 五支(`section_title` / `muted_label` / `space_group` / `primary_button` /
`num_cell`),**每支都要查到调用点**(U4)。浮动窗才用 `section_title`;四张主监控格仍然遵守
BD-UI-31「表题文字撤除,带位即身份」。

**几何教训(本轮实测)**:`Frame::group` 的描边按**内容**矩形画(`frame.rs:486` 用 `content_ui.min_rect()`),
要「预留多大就画多大」必须自己 `allocate_exact_size` + `painter().rect(..., StrokeKind::Middle)`。
`table_heights` 给的是**请求**行高,TableBuilder 实际行高 = max(请求, 格内最高控件):两者不等时
自绘行线整体错位 —— 这就是 0.2.28 治过的毛病 8 的复发路径,行高必须 `max(文字+6, INTERACT_HEIGHT)`。

**已知遗留(交接报告 §7.1 / §13)**:浮动窗自动尺寸导致表头带比表体短(实测 84 px,s69 钉住 ≤ 90);
合约窗视口裁行导致行线计数判据降级(s72 ≥ 10 根,间距与「不压文字中心」仍然严格)。

---

## 0.3.0 — Wave C:下带数据区换 egui_dock(BD-UI-48)


**BD-UI-48(数据区交给停靠框架,框架形状不变)**:菜单条 / 行情带 / 下单侧**不进 dock**,仍是
egui 原生面板;只有下带右侧四组(委托 / 成交 / 持仓 / 资金)换成 `DockState<DataGroup>` 树,
在 `CentralPanel` 里 `DockArea::show_inside`(`DockArea::show` 与 `CentralPanel::show` 互斥)。
格内容仍由 0.2.29 的四个 group 渲染函数出,**dock 只换容器不换内容** —— 这条边界让 Wave A/B 的
表格判据一行都不用改。

**为什么先 spike**:§3 明写「kittest 能否定位 dock 的 tab 按钮 / 分隔条」未实测,而这一条决定
这一波所有判据钉写不写得出来。spike(commit `52104cd`)六条全过才落地;退回路径是撤依赖 +
回滚 `Cargo.lock`,Wave A/B 成果不受影响。

**spike 换来的三条硬口径**
1. dock 的 tab 节点在 AccessKit 里查得到,但**精确标签查不到**:`query_all_by_label("委托")` = 0,
   `contains("委托")` = 1(标签比标题长)。判据钉一律 `contains`。
2. `DockState::new(vec![..])` 把所有 tab 塞进**同一个叶子**;要 2×2 必须显式 `split`
   (`Right 0.5` + 两半各 `Below 0.5`)。不 split 时四组挤成一列 —— S4 直接红。
3. kittest 里 `SidePanel::right().default_width(300)` 没吃到 300(实测侧宽 ≈ 96)→
   判据不许假设「1280 − 300」,中央范围必须从实测推。

**判据形状的选择**:V2 钉的是**树**(4 叶子 + 3 分隔节点 + 每刀 `fraction == 0.5`),不是像素。
原因:渲染侧「委托 / 资金」这些标签在 dock tab 与格内二级 tab 上都会出现,拿标签矩形当列边界会
量到错的节点;渲染侧的 50:50 由快照 `s3 / s42` 钉住。这是**口径降级还是口径更正,要写清**:
Wave B 的 U6 钉的是「我们自绘的四格边框等宽」,那个容器已随 `group_frame_plain` 退役 ——
U6 删除、V2 接替,不是把 U6 放宽。

**样式纪律**:dock 的 `Style` 只走 `Style::from_egui(ui.style())`(吃 `tokens::apply` 填好的
egui `Style`),`dock.rs` 里不得出现 `from_rgb` / `Color32::`(V5 机械扫)。
---

## 0.3.1 — Wave D(部分):排序 / 双击 / 格式定死(BD-UI-49)


**BD-UI-49(展示格式与排序都收进一层,不散在各表)**:数字与时间格式只有 `src/format.rs` 一份口径;
排序键只有 `UiShared::table_sorts` 一份,而且**排序只作用在喂表前的快照副本**(`sorted_rows`)上,
数据模型不被界面排序改写。这条边界就是 Python `widget.py:396-419`「更新途中重排会错位」那个病的正解。

**时区口径(动手前先定死,Wave D 测试方案 §0)**:表格时间列 = 北京 +08 墙钟,与日志同源;
K 线钟面 = UTC 重标定,不动。判据一律喂固定 epoch,不读当前时间。

**本轮实撞的三条 egui/kittest 口径(写下来免得后人重踩)**
1. `egui_extras` 的 `.sense(Sense::click())` **不吃表头格** —— 表头格只有 hover 感。
   先试「格内 `interact`」失败,正解是**表建完后在同一层(横向 ScrollArea 的内层 ui)补 `interact`**;
   格内的 ui 被 TableBuilder 借走,而且坐标必须落在同一层里才对得上。
2. kittest 的 `Node::click()` 走 AccessKit 动作,**静态文本节点没有动作** —— 点了等于没点。
   判据要的是真实输入事件:`Harness::input_mut()`(0.33.3 里不带闭包,直接返回 `&mut RawInput`)
   往同一拍事件队列塞 `egui::Event::PointerButton`(0.33 不叫 `InputEvent`)。
3. 判据挑节点**不能只按 y 筛**:成交表与委托表的表头带在同一行,只按 y 筛会挑到委托表的「价格」
   (实锤:点击落在 x=746,而成交表的「价格」格在 x≈1200)。必须同时按 x 筛。

**没做**:`#[ignore]` 两条 —— W5(右键菜单代码在,判据没定对形状)、W7(退出确认没实现)。
理由字符串写在测试上。这是 Wave D 的欠账,不是已完成。
**Wave D2 补的两条(同版本 0.3.1,提交 `d2a641e`)**

- **退出确认走 egui 0.33 的 viewport 关闭协议,不走宿主**:`ctx.input(|i| i.viewport().close_requested())`
  为真且未放行 → `ViewportCommand::CancelClose` + 弹确认窗;放行位 `exit_confirmed` 置起后重发
  `ViewportCommand::Close`。这条完全住在 ek-ui 里,**没动 ek-station / 宿主一行** ——
  本来以为要在 `main.rs` / `runtime.rs` 挂 `on_close_event`,那是 Qt 的思路,不是 egui 的。
- **判据形状要跟着机制走**:kittest 没有「点窗口关闭按钮」这个动作,所以 W7 钉的是机制形状
  (拦关闭 + 放行路 + 状态归属)。实窗证据(0.3.1 当时):优雅关闭(taskkill)后进程存活 +
  确认窗在场。0.3.2 起「看确认窗长什么样」这条改为**实跑行为核**:启动 → 请求关窗 →
  进程不退(优雅 `taskkill` 后仍在),不出图。
- **右键菜单的教训(挂起前定性的东西)**:同一个二级点击,普通按钮的 `context_menu` 在 kittest 里查得到,
  体表格的格查不到 —— 差别在**控件有没有 AccessKit 动作**,不在事件本身。
  以后要钉右键菜单,得给菜单一个真控件(按钮/图标),或者退到源码扫描;别拿静态文本格硬试。

---

## 0.3.2 口径变更:截图 / 读图 / 像素快照全部撤除(交接报告 §17,本轮定死)


**为什么**:这台机器上的模型没有可视能力 —— 既截不了图,也读不了图。原来靠「看一眼」和
「数像素」立起来的判据,在这一轮之后没有任何人能复核;`tests/snapshots/` 里 15 张 `.png`
基线本身成了不可读的死数据。裁定:**判据一律改成代码能自己判的**,计划 / 测试 / 文档里
不再出现「截图、读图、像素扫描、前后对照图」。

**四类判据(取代「快照 + 像素」)**

1. **语义树**:AccessKit 节点在不在、标签是什么、`Node::rect()` 几何(列对齐、行位置、贴边、滚动)。
2. **状态层**:`UiShared` 字段与访问器,纯单测(不建构界面也判得出来)。
3. **源码机械扫描**:`from_rgb` 只许出现在 `tokens`;开关必须显式(`striped` 透传、
   `show_leaf_close_all_buttons(false)`);格式只走 `src/format.rs`。
4. **本帧绘制指令**:`harness.output().shapes` → `Vec<epaint::ClippedShape>` ——
   immediate 模式这一帧的真实绘制指令,确定性、可比、不需要眼睛。

**第 4 类实核出来的口径(本轮踩出来的,写下来免得后人重踩)**

- 图层形状也在 `FullOutput.shapes` 里:`egui/context.rs:2526` 把根 viewport **所有层**
  (`viewport.graphics.drain(..)`)并进来 → toast 画在 `Order::Foreground` 层也钉得住(e1–e4)。
- `ClippedShape.clip_rect` 分得开「画了」与「被裁掉了」:`TextShape::visual_bounding_rect()`
  与 `clip_rect` 求交,交为空 = 这一帧没露出(s27「数据行不许从表头带上方露出」靠这个)。
- **坑 1**:独立 OS 窗(`show_viewport_immediate` 的 Immediate 面)有自己的 FullOutput,
  根 output 里没有它 → 这类判据退回状态层 + 源码扫描(s10 就是这么改的)。
  kittest 里四窗走 Embedded 回退(主窗内 `egui::Window`),形状在根 output 里。
- **坑 2**:同一支 BG4 在画面里到处都是(主窗各表的带底横线、dock 未选中 tab 的 2px 下边线、
  侧栏分隔竖线)。判「这张表的线」必须先用**这张表自己的带**定 x 闸:带 = 盖住该表表头格
  (`Node::rect()`)的 BG4 实心矩形;横线还要求横跨带宽的 80%。硬编码「窗在画面哪一块」的
  老区域在 0.3.0 换 dock 容器后已经错位(s70 第一版就是这么红的)。
- **坑 3**:`query_by_label` 遇同名标签直接 panic(主窗表与合约表共用「交易所 / 名称」)。
  跨表挑格一律 `query_all_by_label` + 按全仓唯一锚点标签(「最小变动」)的 y 筛。
- **坑 4**:`Rect` 没有 `is_empty()`,只有 `is_positive()`;`Rangef` 不从 `RangeInclusive`
  自动推(要 `Rangef::new`);`f32` 不 `Ord`(`.max()` 换成 `fold(f32::max)`)。

**测试里的取数 helper**(`tests/ui_kittest.rs`)

| helper | 出什么 |
| --- | --- |
| `shapes_of` / `clipped_shapes_of` | 本帧形状 / 形状 + 各自裁剪矩形 |
| `filled_rects(color)` | 该色实心矩形(`rect_filled` = fill 同色且描边为空) |
| `header_bands(color, min_w)` | 实心矩形里够宽够高的(高 ≥ 8 px,1px 框线不算带) |
| `hlines(color)` | 该色水平线段 `(y, x 范围)` |
| `texts_containing(needle)` | 文字形状 `(整串文本, 视觉矩形, 实际取色)` |
| `text_color_of(&TextShape)` | `override_text_color` → 第一个非 PLACEHOLDER 的 section 色 → `fallback_color` |
| `text_span_in(region)` | 区域内真被画出来(裁剪后非空)的文字最宽跨度 = 体表宽度 |
| `toast_texts` / `toast_marks` | 右下角(x ≥ 900、y ≥ 500)的 toast 正文 / 「▎」级别标记 |

**逐条改判(原来 → 现在)**

| 钉 | 原来 | 现在 |
| --- | --- | --- |
| s3 | 首帧快照 | AccessKit 几何钉保留 + BG4 实心矩形(宽 > 100)在场 |
| s10 | K 线窗快照 | 表头标签钉 + `kline_symbols()` 状态钉 + `render.rs` 里 ChartConfig 四色源码扫描 |
| s16 | CJK 快照 | 字体挂在 Proportional / Monospace 两族 + 语义树查到中文串 + 这一帧真排出 > 300 pt 宽度 |
| s21 | 满表快照 | 数据格在场 + 三张表列名逐列计数 + 行数按委托号/成交号计数(CTP_7701 两处 = 委托行 + 成交行的委托号格) |
| s27 | 滚动后快照 + 闸外像素 | 表头 y 不动 + 带矩形 + 真画出来的行文字(裁剪后)必须在带底之下 |
| s39 / s40 / s41 | 三张弹窗快照 | 纯语义树计数(测试名也去掉 `_snapshot`) |
| s42 | 整面板快照 | `ctx.style().visuals == tokens::visuals()` + BG1 大面积底 + BG4 整行横线 |
| s63 / s65 | 快照 | 表头标签计数 |
| s67 | 数 `#455364` / `#259AE9` / `#54687A` 像素 | 绘制指令:BG4 带面积 > 4000、ACCENT4 横线 ≥ 1、BG5 tab 底 ≥ 1 |
| s69 / s70 | `band_vs_body` 像素 + 硬编码窗口区域 | 带 = 盖住表头格的 BG4 实心矩形;体表 = 带下方带内真画出来的文字跨度(缺口 ≤ 90 / 带 ≥ 体表) |
| s71 | 带真行的快照 | 竖线线段(egui_extras 列边界,`table.rs:907`)必须落在相邻两列内容之间,或落在最后一列与表右缘之间 |
| s72 | `scan_hlines` 像素扫 + 快照 | `hlines()` 取带内 BG4 水平线段:≥ 10 根、间距均匀、每根在两行之间、离行文字中心 ≥ 5 px |
| t5 | 每行右侧取一个像素 | `visuals.striped == false` + 源码扫描(透传 `.striped(striped)`、无 `.striped(true)`)+ 整帧 0 块 BG2 斑马 |
| t8 | 快照 | 纯 AccessKit 右边界对齐(价列 / 量列 / 档位标签) |
| u3b | 矩形内数红 / 绿像素 | 文字形状实际取色 == RISE / FALL + `rise_fall()` 函数钉 + 源码扫描 |
| e1–e4 | 两次渲染数右下角色带 | toast 正文与「▎」标记的文字形状:内容 + 实际取色 + 位置 + y 序 + 条数 ≤ `SCREEN_CAP` |
| e5 / W7 / V4 / V5 | 已经是源码扫描 | 判据不动,措辞里的「截图」删掉 |

**删掉的东西**:`SNAP_LOCK` / `ensure_wgpu_backend` / `snap` / `snap_budget` / `snap_guard` /
`count_near` / `band_vs_body` / `scan_hlines` / `Shot` + `shot_of` / `ek_px` / `ek_near` /
`ek_bands` / `EK_*_RGB`;`tests/snapshots/` 15 张基线全删;`Cargo.toml` 里 `egui_kittest` 的
`features = ["snapshot", "wgpu"]` 撤除(egui_kittest 没有 default feature,`Harness` /
AccessKit / `run_steps` 不受影响)。

**不许用来变绿的路**:恢复快照基线、`check-package -AllowDirty`、把断言改成永真。

---

## 0.3.2 Wave E — 告警 toast(BD-UI-50)


**BD-UI-50(告警只有一条产生口,toast 只有一层载体)**:告警的产生归 `UiShared::note_gateway_status`
(状态轮询那一拍),toast 的呈现归 `src/toast.rs`(载体 `egui-notify 0.21.0`)。两件事都不许散:
产生口散到某张窗的渲染里 = 那张窗没开就没有告警;载体散成自绘 overlay = 与 dock / 表头覆盖层抢 z 序。

**载体口径(0.21.0 源码实核,交接报告 §6 第 2 条到这里才关掉)**

- `Toasts::show(ctx)` 把卡片画在 `LayerId::new(Order::Foreground, Id::new("toasts"))` 这个**独立图层**
  (`egui-notify-0.21.0/src/lib.rs:211`)→ 不被我们的表头带 / dock 描边压住(方案 C13 的答案)。
  判据能钉到它,是因为根 viewport 的**所有图层**都并进 `FullOutput.shapes`(§17 口径第 ① 条)。
- **toast 是 painter 画出来的,不是控件**:没有 `Response`、没有 AccessKit 节点,`Toast` 的
  `level / caption / width / height / duration` 全是 `pub(crate)`(`toast.rs:62-74`),
  `Toasts.toasts` 也是私有。所以判据只能走**本帧绘制指令**(正文与「▎」的文字形状),不能走语义树。
- **唯一的交互是右上角「❌」**(`lib.rs:387-392` 只对那颗叉做命中测试)。
  → Python 那条「点击卡片跳该合约 K 线」(`alert_toast.py` 的 `mousePressEvent`)**在这个 crate 里做不到**:
  要做得复刻它的锚点与宽度公式(`toast.width` 每帧按 galley 重算,`lib.rs:323-328`)再自建命中层,
  而那份公式我们没有可读的公开面。**本轮不做,记成欠账**(见本节末)。
- crate 没有「同屏上限」开关 → `SCREEN_CAP = 3` 由我们兜(第 4 条进来前先 `dismiss_oldest_toast`)。
- crate 没有 Python 的「左边框 4px 级别色」→ 级别可辨走 `ToastLevel::Custom("▎", Python 色)`,
  **不假装像素一致**。

**位置与节律口径(照 Python `alert_toast.py`)**

| 口径 | 值 | Python 出处 |
| --- | --- | --- |
| 位置 | `Anchor::BottomRight` + `reverse(true)` = 新的在最底、向上堆叠 | `_reposition` 的 `reversed()` |
| 边距 | 20 / 20 | `_margin_right = 20` / `_margin_bottom = 60`(60 是避任务栏,我们画在窗内容区里,不隔) |
| 卡片间距 | 10 | `_gap = 10` |
| 同屏上限 | 3(超限关最旧) | `_max_toasts = 3` |
| 自动关 | 8 秒 | `show_toast(duration=8000)` |
| 级别色 | `#1890FF / #FAAD14 / #FF4D4F` | `AlertToast._LEVEL_COLORS`(45-49) |

级别色只住在 `tokens::alert`(U2 单一色源钉:`src/` 里 `Color32::from_rgb(` 只许出现在 `tokens.rs`)。

**告警产生口(与 Python `AlertEngine` 的对照)**

- 唯一入口 = `note_gateway_status`:`{gw}.status` 读数折成登录态之后,`is_alarm()`
  (登录失败 / 掉线)或 `QueryStarved`(查询断供)就灌一条 toast,文案用**状态细节原文**
  (「资金回报已 31s 未更新」比「查询断供」更可行动)。
- 去重键 = `网关 + 登录态`:同一状态只弹一次,状态换档才算新告警。不去重的后果是每轮轮询一条,
  8 秒自动关与同屏上限 3 都没有意义。
- 原来这条挂在账户面板渲染里(`render_accounts_window` 的状态细节循环)—— **已撤**,
  账户窗没开也必须知道掉线了。E6 的机械扫描钉就是挡这个回潮的。

**判据(e1–e6,全部先红后绿)**

| 钉 | 判据形状 | 红证据 |
| --- | --- | --- |
| e1 | 没有告警的那一帧 vs 灌一条告警的那一帧:右下角必须多出正文文字形状,「▎」必须取 ERROR 色 | 旧代码没有 `toast` 模块,编译面红 |
| e2 | 三级各一条 → 三条「▎」的实际取色 == Python 那三支,且正好三条 | 缺省 `ToastLevel::Error` 的 crate 色是 `#C85A5A`,不是 `#FF4D4F` |
| e3 | 三条正文按 y 升序 = 最早 / 中间 / 最新 → 最新的 y 最大(在最底) | `reverse(false)` 时顺序正好相反 |
| e4 | 灌 5 条 → 屏上 ≤ `SCREEN_CAP` | 不关最旧时 5 条全在 |
| e5 | 源码扫描:`Duration::from_secs(8)` / `Anchor::BottomRight` + `.reverse(true)` / `SCREEN_CAP: usize = 3` + `dismiss_oldest_toast` / 每帧 `drain_and_show` | 开关不显式写就红 |
| e6 | 状态层:轮询一拍出告警 / 同状态不重复 / 转好不弹 / 换档再弹 + 机械扫描「`push_alert(` 在 src/ 只许一处且在 state.rs」 | 旧 `note_gateway_status` 不产生告警 → 第一条就红(0 条) |

**连带口径(本轮实撞,写下来免得后人重踩)**

- **toast 在屏上时 egui-notify 每帧 `request_repaint`**(`lib.rs:434`,退场计时与出现动画)→
  `Harness::run()` 的「等 UI 自己静下来」(max_steps = 4)直接炸。21 处改成 `harness.run_steps(4)`
  定帧数。教训:**只要界面上有活计时器,就不许用 `run()`**。
- `egui_notify::Toasts` **没有 `Debug`**,而 `UiShared` 是 `#[derive(Debug)]` → 直接放
  `Mutex<Option<Toasts>>` 编译不过。`toast::ToastsCell` 就是为这一条存在的(自己决定 Debug 印什么)。
- 常驻队列必须住在 `UiShared`:每帧新建 = 8 秒自动关与同屏上限都没有意义(与 `DockState` /
  `Chart` 同一条纪律,交接报告 §5.3)。

**Wave E 没做的一条(欠账,不是已完成)**

「点击卡片跳该合约 K 线」做不到,原因在库不在我们(`Toast` 无公开交互面,见上)。
今天还有一层:**我们唯一的告警源是账户状态,它本来就没有「该合约」可跳** —— Python 那条能跳,
是因为它的告警来自按合约的 `AlertEngine` 规则;ek-ui 没有按合约的告警规则(数据侧缺口,不是 UI 缺口)。
出路两条:① 等告警带上 symbol 时自建命中层(要复刻 crate 的宽度公式,脆);② 换成自绘 toast
(~100 行,像素与交互全归我们,但推翻「toast 归 egui-notify」这条选型)。**本轮两条都没选**,
按 Wave E 的验收判据(三级色 + 8 秒 + 右下角向上堆叠 + 上限 3)收口。

## 0.3.3 Wave F — 列差集收口(BD-UI-51)


**为什么有这一轮**:六波做完之后,「跟 Python 长一样」这件事还剩一层没核 —— **列差集**。
逐列把我们的表头/表体和 Python 的四个 Monitor 摆在一起,查「我们多一列 / 少一列 / 列序不同 /
同一列取值不同」。核的过程抓到两个真 bug(不是差集,是错),这是本轮最大的收获。

**两个真 bug(判据当场红出来的)**

| bug | 形状 | 为什么能躲这么久 |
| --- | --- | --- |
| 成交表少一格 | 表头 12 列,表体只发 11 个格 → 整行左移一列(`代码` 位印 `名称`),`RIGHT_COLS` 三列也落在错列 | 老判据只查「列名在场」(s21)与列宽几何(t2/t3),没查「表体第 i 格 == 表头第 i 列」 |
| 持仓成本/均价互换 | ek-data `avg_price` = 成本价、`price` = 持仓均价;Python 成本 ← `avg_price`、均价 ← `price`;我们写反 | s21 只查「成本/均价两列都在」,不看值 |

**列差集三条 + 第四条(本轮新查出来的)**

1. **成交表列序**:照 Python `TradeMonitor.headers`(`widget.py:1270-1283`)—— `代码` 首列、
   `成交号` 第 10 列、`成交额` 改名 `成交金额`。**改**。
2. **行情表「交易所」列**:Python `TickMonitor` 有(`widget.py:709-725`)。**不补** ——
   行情带按所分二级 tab,同一视图里每行「交易所」恒等于 tab 名,加列是纯噪还占宽度。
3. **委托表「操作」列**:Python `OrderMonitor` 没有(`widget.py:1341-1358`)。**保留** ——
   撤单是危险操作,明钮比「双击 + tooltip」可审计(Python 的两条我们也都有)。
4. **枚举值显示语言**(新查出,不在原计划):我们印 wire 串(`LONG / OPEN / PARTTRADED / SHFE`),
   Python 印中文(`多 / 开 / 部分成交 / 上期所`);ek-data 有 `chinese_alias()` 可直取。
   **本轮不改**:牵动下单面板下拉、CSV 导出、排序键与既有判据(s11 按 `PARTTRADED` 查树、
   s18 按 `SHFE.rb2605` 点合约),值得单独一版。已写进交接报告待办。

**判据(f1–f4,全部先红后绿)**

| 钉 | 判据形状 | 红证据(0.3.2 上跑) |
| --- | --- | --- |
| f1 | 成交行 12 个夹具值各一处 + 表体按 x 升序 == 期望序列 + 表头 12 列按 x 升序 == 期望序列 | `F1SYM` 查不到(那格没建构);补格后列序仍错也红 |
| f2 | 持仓行:成本表头 < `98.25`(avg_price) < 均价表头 < `101.5`(price) < 最新价表头 < `77.125` | 成本格印的是 `price` → 第一条就红 |
| f3 | 源码扫描:`TRADE_HEADERS` 字面量逐个按 Python 序出现,且是单源 12 列常量 | 成交号在首列 → 红 |
| f4 | 源码扫描:`render.rs` 里不许有带精度/符号/对齐的裸 `format!` 说明符(排序键两支放行);`format.rs` 必须齐六支 | 持仓/资金/涨跌幅/最小变动共 10 处裸格式 → 红 |

**Wave D 欠账收口的三条(同一版本 0.3.3,BD-UI-52)**

| 钉 | 判据形状 | 红证据(0.3.2 上跑) |
| --- | --- | --- |
| w5 | 右键首格 → 菜单「调整列宽 / 保存数据」两节点在场;**再点「调整列宽」必须真的翻 `table_resize_on`**;**再点「保存数据」必须落 `target/ek-ui-trade_grid.csv`,且表头逐字 == 屏幕表头、每行 12 字段** | 原来整条 `#[ignore]`(体表格不吃二级点击)→ 0 节点 |
| w8 | 委托表点「价格」表头 → O-2(3.5) 到 O-1(101) 之上;再点翻降序 | `table_sort("order_grid") == None`,行序不动 |
| w9 | 持仓表点「数量」表头 → y 序 1 < 3 < 5 | `table_sort("pos_grid") == None` |
| w10 | 行情带点「最新价」表头 → 5 < 98 < 12345(缺省序是按 symbol,所以必须**变**) | `table_sort("tick_grid") == None` |

**这一段自查里揪出来的假判据(留着给后人看:判据自己也会骗人)**

- `f3` 的第一版在**整份 render.rs 里做子序列查找**(「这些列名按这个先后出现」)——
  实测把 `TRADE_HEADERS` 前两列对调它**照样绿**,因为它命中的是更靠前的 `ORDER_HEADERS`。
  改成「先切出 `TRADE_HEADERS` 字面量块,再逐个取字符串字面量比序」后,错序当场红。
  **教训:源码扫描必须先把判据对象切出来,再比;全文件 find 出来的绿是假绿。**
- `f4` 的第一版只认 `format!("{:...`(无名参数)两种子串,`format!("{price:.2}")` 这种
  **命名参数**会整条漏过去。改成逐条 `format!` 抠 `{...}` 说明符再判(带 `.` / `+` / `>` 即违规),
  只放行排序键那两支零填充。
- `w5` 的第一版只数「菜单项节点存在」—— 挡不住「菜单项挂了个空壳」。补了「点下去状态真的变」
  与「CSV 真的落地且列数与屏幕同源」两条。

**连带口径(写下来免得后人重踩)**

- **表头 `interact` 一直是通的,缺的只是「有人接 `clicked`」**。委托表/持仓表/行情带 0.3.1
  起就能点出 `clicked`,只是调用点丢掉了返回值 —— 补排序是「接线」不是「加交互」。
- **被横向裁剪的格仍在语义树里,矩形也还在**(表体节点不受 `clip_rect` 影响)。
  于是「靠 x 窗挑这一行的格」会把**邻表溢出列**也挑进来(实测:1280 宽时委托表 17 列装不下,
  它的「时间」表头矩形伸进成交表的 x 窗,同一行里出现两处「时间」)。
  正解:① 判据夹具给宽窗(2600×900),两张表都不横向裁剪;② 或用「该表专属表头」定行 y。
- **列序改了,老判据的锚点会搬走**:W1/W3/T4/W5 原来都锚在「成交号」或 `T-1`(成交号列),
  列序照 Python 之后它们落到横向裁剪之外 —— **裁剪外的坐标点不到那一格**
  (节点还在树里,命中测试不吃)。四处锚点全部改到「代码/名称」列。
- **表头文字挂排序箭头后,精确标签匹配会失手**:点过一次的表头变成 `价格 ▲`,
  再要抓它必须用 `_contains`(W8 第二次点击即此形)。
- **`context_menu` 挂真控件才收得到二级点击**(W5 的定性):体表格的格先吃过 hit-test,
  体表层后补的 `interact` 与表不同层 → `secondary_clicked` 永远为假。
  首格改成 `Label + Sense::click()` 之后菜单就绿了 —— 结论不是「kittest 不行」,是「挂错了层」。

**欠账(不是已完成)**

- 行情带/委托表/持仓表**排序入口有了,但列宽拖动仍只有合约表**(`resizable` 口径未变);
  右键菜单里的「调整列宽」是全局开关(`table_resize_on`),不是逐表拖边界。
- 「保存数据」只在成交表现有(CSV 走 `trade_cells`,与屏幕同源);委托/持仓/行情带没有导出。
- **排序键的列号与表头列号只有三处交叉钉**(w8 钉委托表第 7 列价格、w9 钉持仓表第 5 列数量、
  w10 钉行情带第 3 列最新价)。若日后有人**同时**改 `ORDER_HEADERS`/`POS_HEADERS` 与对应
  `*_sort_key` 的列号,其它列的点表头会静默排错列 —— 目前没有一条钉能全列覆盖。
  真要补,形状是「点第 i 个表头 → 行序 == 按该列显示串排序」(12 列 × 4 张表 = 48 次点击,
  成本明显,故未做)。**这是本轮留下的已知缺口,不是已覆盖。**
- 委托表第 17 列「操作」没有可排的数据(排序键给空串):点它只挂箭头不换序 —— 无害但会让人
  以为坏了。要治就得把「操作」列从表头点击目标里摘出来(现在 `pinned_table_impl` 对每列一视同仁)。
- **右键菜单只挂在成交表首格(代码)那颗真控件上** —— 横向滚到右边再右键别的格不会出菜单。
  Python 是整表右键。要治得给每行一颗覆盖全行宽的真控件(或等 egui 让体表格的格报二级点击),
  本轮未做。
- 「保存数据」的落点仍是 **`cwd/target/ek-ui-<表>.csv`**(0.3.1 起如此):生产里 cwd 由快捷方式
  决定,不确定 —— 应收成用户文档目录或让用户选路径。另:CSV **不做字段转义**,今天是安全的
  (成交表 12 列全是代码/枚举/数字/时间),但**加任何自由文本列之前必须先转义**,否则一个逗号就错列。