ek-ui 0.6.1

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
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
//! 唯一渲染入口(L2):真实 eframe App 与 kittest 建构走同一个 `render_body`。
//!
//! 布局 v7(0.4.1 BD-UI-54:拆停靠库、回归原生容器;蓝本 = Python 主窗 mainwindow.py):
//! **TopBottomPanel(菜单) → TopBottomPanel(行情带) → SidePanel(下单) →
//! CentralPanel(数据区 2×2 = 薄 QSplitter 两刀) → Window 弹窗**。
//! - 行情带 = 六所固定 tab 条(`state::MARKET_ORDER`,用户裁定「每个交易所都要一个」;
//!   标签 = 交易所**中文名**,条在**表之下**且先给它预留高度不许外溢 —— 九期 BD-UI-62)
//!   加当前所的行情表;每个所都有自己的表与空态。只有 tick 表,没有按所的滚动表
//!   —— 唯一源 = `eTick.` 订阅流,不从 bar 回。行内任意格单击 = 带入下单面板
//!   (ek 增值,方案钉:盘口四列带方向,买1 价/量 → 卖,卖1 价/量 → 买,价格列
//!   顺带填价,其余列方向保持默认 —— 0.2.1 `import_tick`),双击 = 弹 K线窗。
//! - 下单侧(`SidePanel::left`,缺省 300 ≈ Python `setFixedWidth(300)`):下单面板
//!   (Python TradingWidget 等价:9 字段 + 委托 + 全撤;表单 = 网格,按钮/提示语都是
//!   网格行;网格后置 err label 由 ScrollArea 吸收 sizing 退让,BD-UI-27)+
//!   盘口(买5卖5,Python TradingWidget 内序:字段钮 → 盘口)。
//! - 数据区组容器:委托|未成交 / 成交 / 持仓 / 资金|日志 四组(Python 四个 QTabWidget
//!   等价,tab 条 = 自绘 `tab_label`),2×2 两刀 = `crate::splitters`(份额可拖,
//!   态在 `shared.data_split`,恢复布局回 50:50);撤单钮仅活动行,既有 s11 钉口径;
//!   委托行双击 = 撤单,Python OrderMonitor 等价;格内宽表走横向滚动,D7 口径)。
//! - 弹窗两式(BD-UI-32):运维窗(菜单「运维」:插件/服务/依赖;0.2.10 起
//!   流控查询钮随 sim 退场撤除)、
//!   CTP 配置窗(菜单「网关」,CTP 插件自己的界面)= egui::Window 依附主窗;
//!   查询合约窗(菜单「查询合约」:查询 + 勾选 + 订阅钮,Python ContractManager
//!   等价)、K线窗(双击弹出,按合约多开)= 独立 OS 窗(show_viewport_immediate,
//!   原生最小化/最大化/关闭,可拖出主窗;后端不支持多窗自动回退 egui::Window,
//!   kittest 走回退面断言)。
//! - 订阅两入口(用户裁定,网关窗不定死订阅):下单面板代码框**回车**订阅
//!   (Python returnPressed 等价);查询合约窗**勾选订阅**。合约数据 =
//!   `eContract.` 自动回流(ek-ctp 登录后自动查询,UI 只收不催)。
//! - 回灌表单与票据文本框已退役(界面面);总线口(`UiCmd::Reload` 等)保留。
//!
//! kittest 断言面:各单元格独立 Label(`get_by_label` 直查);操作走
//! `push_cmd` 入队(ui 插件 eTimer 辞行出队),确认状态就地翻位,零自制通道。
//! 隔离红标:行状态含 Quarantin(快照扫描,BD-UI-17)。

use ek_data::constant::{Direction, Offset, OrderType};
use ek_station::SVCTL_NAME;
use ek_station::wire::PlugRow;

use crate::plugin::UI_NAME;
use crate::state::{
    BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, beijing_tz, is_quarantined,
};

/// 取消待确认按钮标签。
pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
/// 下单提交按钮标签(v4 = Python「委托」钮)。
pub const BTN_SEND_ORDER: &str = "委托";
/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
pub const BTN_CANCEL_ALL: &str = "全撤";
/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
pub const BTN_CANCEL_ORDER: &str = "撤单";
/// 账户面板:添加账户钮(派生注册名 → svctl.start + {gw}.configure)。
pub const BTN_ACCOUNT_ADD: &str = "添加账户";
/// 菜单项:运维(弹运维窗)。
pub const MENU_OPS: &str = "运维";
/// 菜单项:账户(弹账户面板;多账号面入口,规划 §5.B。旧「网关」单账号窗
/// 已退役收纳进账户面板的添加/编辑表单,旧常量随之删除)。
pub const MENU_ACCOUNTS: &str = "账户";
/// 菜单项:帮助。
pub const MENU_HELP: &str = "帮助";
/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
pub const MENU_CONTRACTS: &str = "查询合约";
/// 菜单项 → 弹窗入口钮(BD-UI-31 一键直达:菜单钮即入口,一步弹窗,
/// 不再挂下拉层;入口钮标签 = 菜单名本身)。
/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
/// 运维窗:装配显形手动重问钮(0.5 M3 —— 管线读数改吃事件后唯一的手动口子)。
pub const BTN_PIPELINE_REFRESH: &str = "重问管线";
/// 运维窗「配置托底」节:段目录刷新钮(0.6.0 环节8)。
pub const BTN_CFG_REFRESH: &str = "刷新段落目录";

/// F4 口径句(0.2.20,D-UI-F4-SCOPE):账户面板底部与配置弹窗**共用这一条常量**。
/// 两处各写一遍字面量 = 将来不同式的坑(s57 数的是"两处都在")。
pub const TXT_PRODUCT_CLASS_SCOPE: &str = "收录勾选只管该网关自己收录并上抛的合约;公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)。改勾选须『连接/重试』才下发到网关。";

fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
    if armed_on == Some(name) {
        format!("确认移除 {name} ?")
    } else {
        format!("移除 {name}")
    }
}

/// 订阅路由(规划 §5.C):行情订阅一律发往**行情元账号**。
///
/// 未指定行情源时回 `Err` —— 不静默发给某个从账号(纯 TD 账号收到订阅会
/// 恒 `ok:true` 但永远不来 tick,是观测黑洞)。
fn subscribe_target(shared: &UiShared) -> Result<String, String> {
    let md = shared.md_source();
    if md.is_empty() {
        return Err("未指定行情源:账户面板点某账户的『设为行情源』后再订阅".to_string());
    }
    Ok(md)
}

fn state_text(r: &PlugRow) -> String {
    r.state.clone()
}

/// 运维窗「统计」列的颜色(0.4.5,P7d;0.5 M6 加第三个参数)。
///
/// 判据按**最坏的那一项**:`0 / 0` → MUTED(这一格的意思是「没事」,不是数据);
/// 溢出 > 0 → WARN;溢出跨过**这一行自己的**邮箱容量或有任何 panic → ERROR。
/// 容量必须传进来:0.5 起 ui 覆写了 `mailbox_cap`(10 万格),还按 ek-core 缺省 1024
/// 判,就会把「ui 丢了 1024 条」染成「溢满一整轮」的红色 —— 而它那一轮是 10 万条。
/// 公开是为了让这一判据可钉 —— 颜色本身在 kittest 里只能靠形状颜色判,
/// 把决策抽成纯函数才能直接断言。
pub fn stat_color(spilled: u64, panics: u64, cap: u64) -> egui::Color32 {
    if panics > 0 || spilled >= cap {
        crate::tokens::semantic::ERROR
    } else if spilled > 0 {
        crate::tokens::semantic::WARN
    } else {
        crate::tokens::semantic::MUTED
    }
}

// 0.3.1(Wave D,BD-UI-49):时间列格式收进 `crate::format::time_hms_millis`(`HH:MM:SS.mmm`)。
// 口径没变:北京时间 +08,数据面时间戳统一存 UTC 纳秒 —— CTP 回报的自然日本就是北京钟面,
// 入账按 +08 换算存 UTC,展示面换回来,表内时间列与交易员作息同钟面,且与日志同源。

// 展示面统一时区 = `crate::state::beijing_tz`(0.2.27 提到 state:日志入环打戳
// 与表格时间列必须同一个口径,一份实现两处共用)。这里不留 doc comment ——
// 它后面没有 item,clippy `empty_lines_after_doc_comment` 正是钉这种。

// ---- BD-UI-31(R3)行高亮:由表格层承担(0.2.29 BD-UI-44 定口径,0.4.1 BD-UI-55 换库)----
// 原来这里有两支手绘助手:`paint_row_bg`(行底色 = 各格响应矩形并集 + 硬编码半透明蓝)
// 和 `click_cell`(在标签矩形上叠一层点击感应)。两支助手在 BD-UI-44 撤除:底色改吃主题
// (`visuals.selection.bg_fill` / `widgets.hovered.bg_fill`,整行连续),格可点 = 表格层
// 自己给的 Response。BD-UI-55 迁 egui_table 之后这条口径没变,只是实现搬到 delegate 的
// `row_ui`(选中 / 悬停底色,行带全宽)与 `pin_cell`(格点击感应)。行底色仍跟主题
// (0.2.27 斑马纹同口径:唯一主题下 `visuals.striped = false`,不许硬开)。

/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
fn order_active(status: ek_data::constant::Status) -> bool {
    ek_data::constant::ACTIVE_STATUSES.contains(&status)
}

/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
    let mut f = shared.order_form();
    f.symbol = t.symbol.clone();
    f.exchange = exchange.to_string();
    match col {
        8 => {
            f.direction = Direction::SHORT;
            f.price = crate::format::price(t.bid_price_1);
        }
        9 => f.direction = Direction::SHORT,
        10 => {
            f.direction = Direction::LONG;
            f.price = crate::format::price(t.ask_price_1);
        }
        11 => f.direction = Direction::LONG,
        _ => {}
    }
    shared.set_order_form(f);
}

/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
    if symbol.is_empty() {
        return None;
    }
    shared.tick_rows().into_iter().find(|t| {
        t.symbol == symbol
            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
    })
}

/// 字号 + 间距 + 控件规格 + 主题视觉(0.2.30 起全部走 `crate::tokens`;主题只有一套
/// Qt深色,`Theme` 枚举与菜单循环钮已删 —— 裁定 8)。真实 App、独立子窗(immediate
/// 共享同一 Context)、kittest 三处同源。
/// 语义色(涨红跌绿 / 多红空绿 / 警告橙 / 错误深红 / 成功深绿 / 次级灰)不在这一层:
/// 它们在 `tokens::semantic` 里恒定,渲染侧直绘,不随视觉档漂移(金融语义不玩配色)。
pub fn apply_ui_style(ctx: &egui::Context) {
    ctx.style_mut(|style| {
        crate::tokens::apply(style);
    });
    ctx.set_visuals(crate::tokens::visuals());
}

/// 表格与页签的「chrome」配色(表头带、分隔线、tab 头底、选中 tab 下划线)。
///
/// 为什么要有这个结构:shadcn 有「表头带底」(`secondary` `#262626`)和「选中 tab 底 +
/// 下划线」这两个概念,Python 侧对应 qdarkstyle 的 `QHeaderView::section`
/// (表头带底 `#455364`)与 `QTabBar::tab:top:selected`(选中 tab `#54687A` 底 + 3px
/// `#259AE9` 下划线),**egui 的 `Visuals` 里没有对应字段**(它只有 `selection.bg_fill`
/// 一根,且 `selectable_label` 只能整块填充)。所以这一层只能在渲染侧直绘。
///
/// 取色口径(审计「多余」条):不新造颜色,全部从主题**已经声明过**的字段里取 ——
/// 带底取 `widgets.inactive.bg_fill`,描边取 `noninteractive.bg_stroke`,表头文字取
/// `noninteractive.fg_stroke`,选中 tab 底取 `widgets.hovered.bg_fill`,下划线取
/// `hyperlink_color`。0.2.30 起主题只有一套,原来「按主题分叉」的那条 match 随之消失。
///
/// 0.4.1 四期(BD-UI-57)拆掉一根旧耦合:旧 qdark 里 `BG4` **同时**是表头带底和一切描边
/// (`noninteractive.bg_stroke` = `#455364` = 带底),所以 `line` 一支两用。Nova 把这两件事
/// 分成 `secondary` `#262626`(带底)与 `border`(白 10%,一切描边)→ 于是多出 `band_fill`
/// 这一支,`line` 只管线不管底。
///
/// 0.4.0 期间 tab 那四支随「组内二级 tab 条撤除、tab 归 dock」删过一轮;0.4.1
/// (BD-UI-54)tab 形态回归原生(自绘 `tab_label`),四支原样归位 —— 派生路径
/// (同一份 `visuals`,`s67` 钉住)不变。
#[derive(Clone, Copy)]
struct Chrome {
    /// 一切分隔线 / 网格线 / 行线 / 窗框(1px)。旧口径这一支兼做表头带底,现已拆开。
    line: egui::Color32,
    /// 表头带底 + 未选中 tab 的头底。
    band_fill: egui::Color32,
    /// 表头带文字。
    header_text: egui::Color32,
    /// 选中 tab 的底。
    tab_fill: egui::Color32,
    /// 选中 tab 的文字。
    tab_text: egui::Color32,
    /// 选中 tab 的下划线(qss 3px 那支)。
    tab_underline: egui::Color32,
}

impl Chrome {
    /// 从当前 visuals 派生(唯一主题 → 唯一派生规则,不再按主题分叉)。
    fn of(v: &egui::Visuals) -> Self {
        Self {
            line: v.widgets.noninteractive.bg_stroke.color,
            band_fill: v.widgets.inactive.bg_fill,
            header_text: v.widgets.noninteractive.fg_stroke.color,
            tab_fill: v.widgets.hovered.bg_fill,
            tab_text: v.widgets.hovered.fg_stroke.color,
            tab_underline: v.hyperlink_color,
        }
    }
}

/// 页签(BD-UI-41,对照 qdarkstyle `QTabBar::tab:top`)。
///
/// Python 侧四个区全是 QTabWidget(`mainwindow.py:106-204`,连只有一个 tab 的
/// 「成交」「持仓」也带 tab 头),qss 给 tab 的形态是:
///   未选中 `QTabBar::tab:top { background #455364; padding 2px 4px }` +
///     `!selected { border-bottom: 2px solid #19232D }`(qss:1579 / 1559)
///   选中   `QTabBar::tab:top:selected { background #54687A;
///     border-bottom: 3px solid #259AE9; 上圆角 4px }`(qss:1592)
/// egui 的 `selectable_label` 只有「整块 selection 填充」,画不出下划线,也没有
/// 「未选中也有头底」这一层。所以这里照 pinned_table 表头带同一套路子:
/// **控件仍走 selectable_label**(可访问性标签 / 点击 / 键盘都不换),只是随后用
/// painter 盖上头底 + 下划线,并重画文字(epaint 0.33 没有 z-order,后画的填充
/// 会盖住先画的文字,所以文字必须重画 —— 与表头带同一处理)。
fn tab_label(ui: &mut egui::Ui, selected: bool, text: impl Into<String>) -> egui::Response {
    let ch = Chrome::of(ui.visuals());
    let text: String = text.into();
    let resp = ui.selectable_label(selected, text.clone());
    let r = resp.rect;
    let radius = ui.visuals().widgets.inactive.corner_radius;
    let font = ui.style().text_styles[&egui::TextStyle::Button].clone();
    let p = ui.painter();
    let (fill, ink, underline) = if selected {
        (
            ch.tab_fill,
            ch.tab_text,
            egui::Stroke::new(3.0, ch.tab_underline), // qss 的 3px 那支
        )
    } else {
        (
            ch.band_fill,
            ch.header_text,
            egui::Stroke::new(2.0, ch.line), // 未选中 tab 的 2px 下边线
        )
    };
    p.rect_filled(r, radius, fill);
    p.hline(r.x_range(), r.max.y, underline);
    p.text(
        r.center(),
        egui::Align2::CENTER_CENTER,
        text.as_str(),
        font,
        ink,
    );
    resp
}

/// 普通 Grid 的表头带(BD-UI-41)。
///
/// 与 `pinned_table` 的覆盖带同一套路子:头行照常在 grid 里占格(列宽由它量出来,
/// 这是唯一权威源),随后 painter 盖上带底 + 下边线并重画文字(epaint 0.33 无 z-order,
/// 填充盖字 → 文字必须重画)。
///
/// 为什么这三处不走 `pinned_table`:它们是**自动尺寸浮动窗里的短表**(账户清单 /
/// 运维插件表 / 服务目录)。0.2.17 实锤(s39):`pinned_table` =
/// `auto_shrink(false)` 滚动区吃满整块可用高 + 覆盖式表头带,在自动尺寸浮动窗里
/// 两者凑不出正确高度 → 表行与其后的口径句互相压字。账户行最多几个,不需要固定
/// 表头滚动面,只要那根带。
///
/// 0.2.29(BD-UI-44)把这条边界写死:数据监控表(行情/委托/成交/持仓/日志/合约/资金)
/// 一律走表格层(0.4.1 BD-UI-55 = egui_table 的 `pinned_table`);自动尺寸浮动窗里的
/// 短表一律 Grid + 这根带。资金表原来在第三类里,实测它和日志表共用同一块 tab 容器
/// (不是浮动窗)→ 已并入表格层一侧。
fn header_band(
    ui: &mut egui::Ui,
    heads: &[(egui::Rect, String)],
    content_right: f32,
) -> egui::Rect {
    if heads.is_empty() {
        return egui::Rect::NOTHING;
    }
    let ch = Chrome::of(ui.visuals());
    // 带的横向范围 = **整行**(这一格 / 这一窗的可用宽),不是表头文字矩形的并集。
    // 根因(0.2.28 用户实看指出「运维窗表头背景没按整行、最后一列只有文字有背景」):
    // `Response::rect` 是控件自己的矩形,不是列框(grid.rs:180-193 —— `next_cell`
    // 给出列框,`align_size_within_rect` 按 LEFT_CENTER 对齐后返回子控件尺寸)。
    // 表头 label 的 rect 宽 = 文字宽。原来按表头 rect 的并集算带 → 带只盖到
    // 最后一列**表头文字**的右端,列内比它宽的部分(运维窗『操作』列的两个按钮、
    // 服务目录的空实例列、资金格右侧)没有带 = 看上去断开一截。
    // 口径照 Python:QHeaderView 铺满整个表视口;也与 `pinned_table` 那条带一致
    // (那条铺的是 ScrollArea 的 inner_rect = 整格宽)。
    let left = ui.max_rect().min.x;
    // 0.2.30(BD-UI-47):带的右端取「可用宽」与**表体实际右端**的较大者。
    // 浮动窗是自动尺寸的,而 `header_band` 在 Grid 之后才画 —— 令牌把控件交互宽
    // 从 egui 缺省 128 收到 40 后,窗内 Grid 的实际宽度比窗的可用宽更宽(s69 实测
    // 带 640 / 体表 727),带就盖不住最后一列。口径不变:带必须盖住整行。
    let right = ui.max_rect().max.x.max(content_right);
    // 带高与表格层**同一个数**(0.4.1 八期 BD-UI-61,用户问「表头的高度都一样吗」):
    // 表格层带高 = 表头行高 `head_h`(26)+ `clip_rect_margin`(3)= 29;短表层原来取
    // 「文字高 + 上下各半间距」= 22 → 同一屏里菜单窗的表头比主窗矮 7 px(探针实测 22.0 vs 29.0)。
    // 现在按同一条公式算,并以**表头行的垂直中心**为带中心 → 带高一致、文字在带里居中。
    let head_h = table_heights(ui).1;
    let band_h = head_h + ui.visuals().clip_rect_margin;
    let mid = heads[0].0.center().y;
    let top = mid - band_h * 0.5;
    let bottom = mid + band_h * 0.5;
    let strip = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom));
    let p = ui.painter();
    p.rect_filled(strip, 0.0, ch.band_fill);
    p.hline(
        strip.x_range(),
        strip.max.y,
        egui::Stroke::new(1.0, ch.line),
    );
    let font = ui.style().text_styles[&egui::TextStyle::Small].clone();
    // 表头文字的锚定线 = **带的垂直中心**(0.4.1 七期 BD-UI-60)。旧口径取 galley 中心
    // `(top + heads[0].0.max.y)/2`,比带中心高 2.5 px → 文字在带里偏上(实测上隙 4.5 / 下隙 8.5)。
    let mid_y = (strip.min.y + strip.max.y) * 0.5;
    for (rect, text) in heads {
        p.text(
            egui::pos2(rect.min.x, mid_y),
            egui::Align2::LEFT_CENTER,
            text.as_str(),
            font.clone(),
            ch.header_text,
        );
    }
    strip
}

/// 判据侧读口(0.4.1 七期 BD-UI-60):这张表这一拍的「列宽总和」与「补宽目标宽」。
///
/// 目标宽 = 所在区域可用宽 − 这张表自己的滚动条占位。判据钉的是「补过之后 Σ ≥ 目标 9 成」
/// —— 表宽 = 所在区域 100% 这条裁定在 kittest 里唯一不靠猜的读法(列线撤除后,列边界
/// 在形状表里已经没有落点了)。
pub fn table_fill_probe(ctx: &egui::Context, table_id: &str) -> (f32, f32) {
    let fill_id = egui::Id::new(("table_fill", table_id));
    ctx.data(|d| {
        (
            d.get_temp::<f32>(fill_id.with("stored")).unwrap_or(0.0),
            d.get_temp::<f32>(fill_id).unwrap_or(0.0),
        )
    })
}

/// Grid 短表的网格线(0.4.1 五期 BD-UI-58,用户裁定「网格线在黑色背景下反而显得整齐」
/// → 表格层与短表层同一口径:每行一根行线、每两列一根列线)。
///
/// - 行线 = 这一行内容底沿往下半个 `item_spacing.y`。Grid 的行间距就是 `item_spacing`
///   (grid.rs:113 / 432),半个间距 = 两行正中;最后一行的行线就是这张表的底边线,
///   与 `pinned_table` 里「行线画在行底」同一条不变式(那边行间距为 0)。
/// - 列线 = 第 2..n 列表头矩形的左沿往左半个 `item_spacing.x`。Grid 格内是 LEFT 对齐
///   (`align_size_within_rect`),控件左沿就是列左沿 → 半个间距正是两列正中。
/// - 表头行那根下边线由 `header_band` 画,这里不重复画第二根。
///
/// `row_bottoms` = 各**表体行**在 `end_row` 前测到的 `ui.min_rect().max.y`。空态(0 行)
/// 时它是空的:没有行线可画,列线只到表头带底(0.4.1 六期 BD-UI-59 起空态不画提示行,短表空态
/// = 只有表头那一行)。
fn grid_chrome(
    ui: &egui::Ui,
    band: egui::Rect,
    heads: &[(egui::Rect, String)],
    row_bottoms: &[f32],
) {
    if !band.is_positive() || heads.is_empty() {
        return;
    }
    let ch = Chrome::of(ui.visuals());
    let sp = ui.spacing().item_spacing;
    let p = ui.painter();
    for &rb in row_bottoms {
        p.hline(
            band.x_range(),
            rb + sp.y * 0.5,
            egui::Stroke::new(1.0, ch.line),
        );
    }
    // 列线(0.4.1 七期 BD-UI-60,用户裁定「表格里所有的列分割线都去掉,只要行的分割线」):
    // 短表层这套自绘列线直接撤除,`heads` 从此只用来判「有没有表头」与定带的宽。
}

/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
///
/// v7 框架(0.4.1,BD-UI-54):TopBottomPanel(菜单) → TopBottomPanel(行情带) →
/// SidePanel(下单)| CentralPanel(数据区 2×2 薄 QSplitter) → 弹窗。
/// 0.4.0 的整窗 dock 拆除。官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、
/// CentralPanel 最后加、Window 挂在所有面板之后。
pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
    apply_ui_style(ctx);
    // 0.3.1(Wave D,BD-UI-49)退出确认:关窗先问一句(照 Python `closeEvent`)。
    // 拦关闭用的是 egui 0.33 的 `ViewportCommand::CancelClose`;用户在窗里点「确认退出」
    // 才置放行位并重发 `Close` —— 否则确认完也关不掉。
    let close_requested = ctx.input(|i| i.viewport().close_requested());
    if close_requested && !shared.exit_confirmed() && !shared.exit_confirm_open() {
        ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
        shared.open_exit_confirm();
    }
    // 0.3.2(Wave E,BD-UI-50)告警 toast 每帧收口(队列常驻在 UiShared,不是这里的局部变量)。
    crate::toast::drain_and_show(ctx, shared);
    if shared.exit_confirm_open() {
        egui::Window::new("退出确认")
            .collapsible(false)
            .resizable(false)
            .show(ctx, |ui| {
                ui.label("确认退出 ek-ui?");
                ui.horizontal(|ui| {
                    if ui.button("取消").clicked() {
                        shared.close_exit_confirm();
                    }
                    if ui.button("确认退出").clicked() {
                        shared.confirm_exit();
                        shared.close_exit_confirm();
                        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
                    }
                });
            });
    }
    // ---- 菜单栏(顶层面板,最先 = 最外) ----
    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
        render_menu_bar(ui, shared);
    });

    // ---- 上带:行情带(全宽独占,可拖分隔条;缺省高 = Python v_splitter 30:70 分带) ----
    egui::TopBottomPanel::top("market_band")
        .resizable(true)
        .default_height(crate::tokens::MARKET_BAND_HEIGHT)
        .show(ctx, |ui| {
            // 外层 ScrollArea auto_shrink(false) = resizable 面板吃满前提(BD-UI-27
            // 口径;s3 实锤:撤掉后空态带塌成一行,2×2 行位全漂移)。内层
            // pinned_table 自己会收缩适配 → 外层滚动条永不出现。
            egui::ScrollArea::both()
                .id_salt("market_band_scroll")
                .auto_shrink([false, false])
                .show(ui, |ui| {
                    render_market_band(ui, shared);
                });
        });

    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget setFixedWidth(300)) ----
    egui::SidePanel::left("order_side")
        .resizable(true)
        .default_width(crate::tokens::ORDER_SIDE_WIDTH)
        .show(ctx, |ui| {
            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
            egui::ScrollArea::vertical()
                .id_salt("order_side_scroll")
                .auto_shrink([false, false])
                .show(ui, |ui| {
                    render_order_panel(ui, shared);
                    ui.separator();
                    render_book_frame(ui, shared);
                });
        });

    // ---- 下带右侧:数据区 2×2(薄 QSplitter 两刀;份额可拖,态在 UiShared) ----
    egui::CentralPanel::default().show(ctx, |ui| {
        render_data_area(ui, shared);
    });

    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
    render_windows(ctx, shared);
}

/// 行情带 = 当前所的行情表 + 六所固定 tab 条(0.4.1 BD-UI-54 回归原生容器;
/// 「每交易所一格」= 六个 tab,每个 tab 都有自己的表与空态,s73 口径不变)。
///
/// 0.4.1 用户裁定(2026-10-22):**tab 条放在表下面**(原来在表上面)。
/// 这是对 Python 侧 `QTabWidget` 缺省 tabPosition=North 的一处偏离,记 DESIGN。
/// 判据面不受影响:六所 tab 序钉(V2)与「行情带横跨全宽」钉(s3)取的都是
/// tab 标签在场 + 格内容左沿/高度关系,与 tab 条在表的哪一侧无关。
/// 交易所 tab 名 = **中文名**(0.4.1 九期 BD-UI-62,用户裁定「不要显示交易所的代码,
/// 而是显示交易所的名字」)。叫法照 Python 侧(`xt_gateway.py:1089-1094` 与
/// `datafeed/ak/futures_member_pos.py:14-19` 的映射:中金所=CFFEX / 上期所=SHFE /
/// 郑商所=CZCE / 大商所=DCE / 广期所=GFEX,INE = 能源中心)。
pub fn exchange_tab_name(ex: ek_data::constant::Exchange) -> &'static str {
    use ek_data::constant::Exchange as E;
    match ex {
        E::SHFE => "上期所",
        E::DCE => "大商所",
        E::CZCE => "郑商所",
        E::CFFEX => "中金所",
        E::GFEX => "广期所",
        E::INE => "能源中心",
        // 行情带只有上面六所(`state::MARKET_ORDER`);其余变体回落线上代码。
        other => other.as_str(),
    }
}

fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
    let active = shared.selected_exchange();
    let active_ex = crate::state::MARKET_ORDER
        .iter()
        .copied()
        .find(|e| e.as_str() == active)
        .unwrap_or(crate::state::MARKET_ORDER[0]);
    // **先给 tab 条留出高度,再让表吃剩下的**(0.4.1 九期 BD-UI-62,用户实看:「表格被下面
    // 标签给挡住了,只有滚动才能看到」)。根因:表(内层 ScrollArea)先分配 = 把整块面板高
    // 吃干,tab 条排在它后面就被推到面板底边之外(探针实测 1280×860:面板底 ≈275,
    // tab 标签落在 y 274..300 = 面板之外,还与数据区「委托」tab 头 278..304 压在一起)。
    // tab 条是固定条,不该跟表格滚动抢地方。
    // 预留高度照 `tab_label` 真正用的那一档:`selectable_label` 走 `TextStyle::Button`
    // + `button_padding`(不是 Small —— 用 Small 会少算,窗口一窄就又开始外溢)。
    let tab_h =
        ui.text_style_height(&egui::TextStyle::Button) + ui.spacing().button_padding.y * 2.0 + 4.0;
    let avail = ui.available_size();
    let table_rect = egui::Rect::from_min_size(
        ui.cursor().min,
        egui::vec2(avail.x, (avail.y - tab_h).max(40.0)),
    );
    ui.scope_builder(egui::UiBuilder::new().max_rect(table_rect), |ui| {
        // 表头带的上隙(`clip_rect_margin`)要在子 ui 里留出地方:子 ui 的 clip = 这块
        // max_rect,若表从 cursor 顶开始画,带顶那 3 px 会被裁掉(带高 26 而不是 29,
        // s79「所有表头带同一档」当场红)。
        ui.add_space(ui.visuals().clip_rect_margin);
        render_market_pane(ui, shared, active_ex);
    });
    ui.horizontal(|ui| {
        for ex in crate::state::MARKET_ORDER {
            if tab_label(ui, ex == active_ex, exchange_tab_name(ex)).clicked() {
                shared.set_selected_exchange(ex.as_str());
            }
        }
    });
}

/// 数据区 2×2 = 上下(顶部:委托|未成交 | 成交;底部:持仓 | 资金|日志),
/// 两刀都是可拖的薄 QSplitter(份额在 `shared.data_split`,恢复布局回 0.5)。
fn render_data_area(ui: &mut egui::Ui, shared: &UiShared) {
    let split = shared.data_split();
    // 0.4.1 九期 BD-UI-62:2×2 改走 `splitters::quad`(一根贯穿横线 + 一根贯穿竖线)。
    // 旧口径 = `split_v` 上下分完再在每排里各来一刀 `split_h` → 「竖刀」实为两条独立线段,
    // 悬 / 拖只有被碰的那半亮(用户实看)。份额仍是同一对 `DataSplit { row, col }`。
    crate::splitters::quad(
        ui,
        egui::Id::new("ek_data_quad"),
        split.row,
        split.col,
        |row| {
            shared.set_data_split(crate::state::DataSplit {
                col: split.col,
                row,
            });
        },
        |col| {
            shared.set_data_split(crate::state::DataSplit {
                col,
                row: split.row,
            });
        },
        |ui| render_topleft_group(ui, shared),
        |ui| render_trades_group(ui, shared),
        |ui| render_positions_group(ui, shared),
        |ui| render_bottomright_group(ui, shared),
    );
}

/// 委托|未成交 组(Python tab_widget1:两 tab 同一容器)。
fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
    let tab = shared.topleft_tab();
    ui.horizontal(|ui| {
        if tab_label(ui, tab == TopLeftTab::Orders, TopLeftTab::Orders.label()).clicked() {
            shared.set_topleft_tab(TopLeftTab::Orders);
        }
        if tab_label(ui, tab == TopLeftTab::Active, TopLeftTab::Active.label()).clicked() {
            shared.set_topleft_tab(TopLeftTab::Active);
        }
    });
    render_orders_table(ui, shared, tab == TopLeftTab::Active);
}

/// 资金|日志 组(Python tab_widget4;Account 分支 = 0.4.0 修好的「空态带表头」版)。
fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
    let tab = shared.bottomright_tab();
    ui.horizontal(|ui| {
        if tab_label(
            ui,
            tab == BottomRightTab::Account,
            BottomRightTab::Account.label(),
        )
        .clicked()
        {
            shared.set_bottomright_tab(BottomRightTab::Account);
        }
        if tab_label(
            ui,
            tab == BottomRightTab::Logs,
            BottomRightTab::Logs.label(),
        )
        .clicked()
        {
            shared.set_bottomright_tab(BottomRightTab::Logs);
        }
    });
    match tab {
        BottomRightTab::Account => render_funds_pane(ui, shared),
        BottomRightTab::Logs => render_logs(ui, shared),
    }
}

// 0.4.1(BD-UI-54):数据区组的容器 = 薄 QSplitter 的两块内容区(sql)。格边框与
// 分隔线走色板令牌(`splitters::run_split` 只吃 `tokens::shadcn`,V5 钉住);
// 0.3.0 dock 容器曾接管过这层,0.4.1 拆 dock 后归位。
// 0.2.30 那条教训仍然成立并记在 DESIGN.md:egui 0.33.3 的 `Frame` 没有 `min_size`,
// `Frame::paint` 的描边按**内容** min_rect 算(frame.rs:486)——内容少的格边框会跟着缩。

// 表头固定表(BD-UI-34 用户裁定「每个表的表头都要固定」;0.2.29 BD-UI-44 起换成表格库,
// 0.4.1 BD-UI-55 表格库 = `egui_table 0.7`,用户裁定「表格使用 egui_table」)。
//
// 为什么要表格库(实拍毛病 2/3/5,对照 Python 的 QTableView):
// - **列宽跟内容**:手绘 Grid 的列宽只能取各格 `Response::rect` 的最大值,而
//   `Response::rect` 是控件矩形不是列框(0.2.28 实锤,egui grid.rs:180-193)
//   → 列宽只能"差不多",数字列对不齐。表格库的列宽是库自己量出来的(egui_table:
//   `Column::range` 夹 `max_column_widths`,table.rs:816-843 写回 `ColumnState.width`)。
// - **表头天生固定**:表头行画在体表滚动区之外(egui_table `Table::show` 先 `header_ui`
//   再 `SplitScroll`,table.rs:470-537)→ 0.2.8 起那块「ScrollArea 之后重画覆盖带 +
//   重画表头文字」的 DIY 全撤(当年 DIY 是因为 egui 没有表;scroll_area.rs:412
//   那句「painting a sticky header on top of it」就是 DIY 的出处)。
// - **横向溢出**:成交表 14 列比格子宽,Python 出横向滚动条(毛病 5)。egui_table 的
//   `SplitScroll` 自带横 + 纵两条滚动条:表头跟着体表一起横滚(不错位),竖滚仍归表体
//   (表头不动)。
// - **选中 / 悬停底色吃主题**:egui_table 不提供行选中(全库无 `set_selected`)→ 底色由
//   delegate 的 `row_ui` 自己画,色仍取 `visuals.selection.bg_fill` /
//   `widgets.hovered.bg_fill`,且按行带全宽画 = 整行底色连续
//   → 手绘 `paint_row_bg` 那支硬编码半透明蓝撤掉(与 0.2.27「斑马纹跟主题」同口径)。
// - **网格线(0.4.1 五期 BD-UI-58 起:全部表统一都有)**:列线 = 可拖列边界那根线
//   (egui_table `Column::resizable` + `chrome().line`),行线 = delegate 每行的行底。
//   旧口径是「只有合约表开网格」(Python `widget.py:367` 的 `setShowGrid(False)` 只加在
//   交易表上,合约表 `widget.py:2423`/`2621` 没关,qss `gridline-color:#455364`)——
//   用户 2026-10-22 实看后裁定撤掉这条分叉:「网格线在黑色背景下比较暗,有横向竖向这些线
//   反而显得整齐」→ 交易表跟合约表一样都画,`GridLines` 这个开关随之退役。
//
// body 闭包在表体内执行(行循环归闭包);行高由本模块统一算,body 只写每格内容。
// pub(crate):`pinned_table_impl_cols` 是 pub(crate)(日志表字体量宽入口要用)。

/// 行高 / 表头高 = 字号 + 6。Python QTableView 的 sizeHint ≈ 字体高 + 4~6;
/// 0.2.28 之前 Grid 自然行高在 kittest 实测行间距 21 = 正文 14.7 + 6,同值。
fn table_heights(ui: &egui::Ui) -> (f32, f32) {
    let st = ui.style();
    // 行高必须取「文字高 + 6」与**控件交互高令牌**的较大者。格里有钮 / 勾选框时
    // 表格层的实际行高 = max(请求值, 格内最高控件):请求值 22 < 实际 26 时,
    // 行线公式按假行高算 —— 0.2.30 实锤:行线画在 y=514 而第 3 行文字中心在 511
    // = 0.2.28 治过的毛病 8(行线压到文字中心上)复发。别把这个 max 拆掉。
    let floor_h = crate::tokens::INTERACT_HEIGHT;
    (
        (st.text_styles[&egui::TextStyle::Body].size + 6.0).max(floor_h),
        (st.text_styles[&egui::TextStyle::Small].size + 6.0).max(floor_h),
    )
}

// ================= egui_table 0.7.0 适配层(BD-UI-55,用户裁定「表格使用 egui_table」) =========
//
// egui_table 的形制是「拉」:建 `Table`(列/表头/行数)+ `TableDelegate`(表头/行/格的
// 逐个绘制回调);egui_extras::TableBuilder 的形制是「推」(调用点闭包直接喂行)。
// 本层把推改成拉:调用点的 body 闭包**原样保留**(行循环 + 每格内容),只是行收进
// `PinBody`/`PinRow` 的延迟画笔,show 时由 delegate 逐格执行 —— 每张表调用点只多写
// `selected` 一个参数与 `pin_cell` 一层包装。
//
// 每格左内边距 = 6px(egui_table 离 battery 之外不加 margins,old TableBuilder 的
// item_spacing 缩进由 Frame::inner_margin 补回来,不然文字贴列边框)。

/// 单格延迟画笔(行收集物;腰上 `'b` = pinned_table_impl 的本次调用)。
/// **FnMut 不是 FnOnce**:egui_table 首帧在同一帧里把每格跑两遍(全量量内容的
/// invisible sizing pass + 真绘 pass,库自己 `request_discard`)—— FnOnce 在
/// sizing pass 里就被消费掉,真绘 pass 会全空(0.4.1 实锤:所有格文字消失)。
/// 格内动作要幂等(select_row/set_order_form 等同帧重放语义不变;egui 的
/// request_discard 本来就是「整帧重来」)。
/// 第二个参数 = 这一格的上下文(满格命中层),由 delegate 的 `cell_ui` 造,见 `PinCellCtx`。
pub(crate) type PinCell<'b> = Box<dyn FnMut(&mut egui::Ui, &PinCellCtx) + 'b>;

/// 一格真实绘制时由 delegate 递给调用点的上下文(BD-UI-55)。
pub(crate) struct PinCellCtx {
    /// 满格命中层:在**格那一层**注册的响应(`Ui::interact`,矩形 = 整格)。
    ///
    /// 为什么不是 `ui.allocate_rect(ui.max_rect(), ..)`(BD-UI-55 最大拦路虎,已修):
    /// `allocate_rect` 的口径是「忽略布局,这块地归我」—— 布局 cursor 被推到整格之外,
    /// 之后格内的内容排到**下一格**去。实锤(临时探针 + vendor 出来的 egui 打点,
    /// 脚手架已随本次提交拆除,读数留档在交接报告 §4.1 / 交接报告主文 §21.3):
    /// 格 clip `[901..1001]`,格内 label 的实际矩形 `[1003..1088]` = 错开一整列宽 →
    /// `Label` 的绘制门 `ui.is_rect_visible(response.rect)` 判假 → 表体文字既不画、
    /// 也不进语义树。`interact` 不推进布局(ui.rs:1131 只 `create_widget`),
    /// 满格命中与格内真控件的响应照旧两层并立 = 0.2.29「两边都看」口径不变。
    ///
    /// `interact_rect` = 注册处 ui 的 clip ∩ 矩形(ui.rs:1140),所以这颗必须注册在
    /// **格那一层**(clip = 整格),不能注册在 Frame 内层(clip 已被内边距缩掉 6px)。
    pub hit: egui::Response,
}

/// 一行(PinBody 收集物)。行高统一走 delegate 的 `row_h`(`table_heights`),
/// egui_table 的行是相邻接等高带 —— `body.row(h, ..)` 的 h 现在只是调用点形状兼容,
/// 不再逐行生效(0.4.1 适配层口径)。
struct PinRow<'b> {
    selected: bool,
    cells: Vec<PinCell<'b>>,
}

/// 体表 push 收集器(替代 `egui_extras::TableBody` 的角色;BD-UI-55)。
#[derive(Default)]
pub(crate) struct PinBody<'b> {
    rows: Vec<PinRow<'b>>,
}

impl<'b> PinBody<'b> {
    /// 与旧 `body.row(h, |mut row| ..)` 同形,多一个 `selected`
    /// (选中底色从构建时的 `set_selected` 改为收集物,绘制时 `row_ui` 画)。
    pub fn row(&mut self, _h: f32, selected: bool, add: impl FnOnce(&mut PinRowAdd<'b>)) {
        let mut r = PinRowAdd {
            selected,
            cells: Vec::new(),
        };
        add(&mut r);
        self.rows.push(r.finish());
    }
}

/// 行头的 `PinRow::row` 闭包参数,负责收格。
pub(crate) struct PinRowAdd<'b> {
    selected: bool,
    cells: Vec<PinCell<'b>>,
}

impl<'b> PinRowAdd<'b> {
    /// 一格,与旧 `row.col(|ui| ..)` 同形(形态不同点:内容延迟到体表真实绘制时跑,
    /// 且首帧同帧跑两遍 —— FnMut,见 `PinCell`;多出来的第二个参数 = 这一格的
    /// `PinCellCtx`,由 delegate 的 `cell_ui` 造)。
    pub fn cell(&mut self, make: impl FnMut(&mut egui::Ui, &PinCellCtx) + 'b) {
        self.cells.push(Box::new(make));
    }
    fn finish(self) -> PinRow<'b> {
        PinRow {
            selected: self.selected,
            cells: self.cells,
        }
    }
}

/// 与旧 `tcell(row, right, make)` 等价的一格:格响应 = 整格可点那一层
/// (`PinCellCtx::hit`,在格那一层注册的 `interact`),格内控件自己的响应跟随
/// `CellOutcome::response()`;命中判定(旧宏里 `cell_r.clicked() || inner_r.clicked()`)
/// 由这两个响应合成:返回 `(cell_r, inner_r)`,闭包内接着写行动作 ——
/// 与 0.2.29 的「两边都看」完全同口径(egui 一颗点击只发给一个响应,见 `CellOutcome`)。
pub(crate) fn pin_cell<R: CellOutcome>(
    ui: &mut egui::Ui,
    ctx: &PinCellCtx,
    right_align: bool,
    make: impl FnOnce(&mut egui::Ui) -> R,
) -> (egui::Response, Option<egui::Response>) {
    let inner = if right_align {
        crate::ui_ext::num_cell(ui, make)
    } else {
        make(ui)
    };
    (ctx.hit.clone(), inner.response())
}

/// SplitScroll 的**退化象限**判定(BD-UI-55 实查;`row_ui` 用)。
///
/// `num_sticky_cols = 0` 时左上/左下象限的 rect 恒零宽,可 egui_table 无条件调四个
/// 象限的 delegate(split_scroll.rs:79-151)→ 左下象限里每一行都会调一次 `row_ui`。
/// 那里画不出任何东西(裁剪矩形零面积 → tessellator.rs:1367 直接丢),但选中底色 /
/// 行线仍会被登记进本帧形状表 —— 判据读的正是形状表,所以就地不画。
///
/// 判据 = 「clip 与格矩形**共边**(宽恰为 0)而格矩形本身正」。这与「格在视口之外」
/// (clip 与格矩形不相交 → 宽为**负**)是两种不同的退化,后者不能砍:首帧列宽量测靠的
/// 就是「全列都跑一遍」(table.rs:633-637 的 `do_full_sizing_pass` → `col_range = 0..len`)。
/// 表头那一侧这两种退化在几何上长得一样,分不开 → `header_cell_ui` 改用调用序判据。
fn quadrant_is_degenerate(ui: &egui::Ui) -> bool {
    let c = ui.clip_rect();
    let m = ui.max_rect();
    (c.width() == 0.0 && m.width() > 0.0) || (c.height() == 0.0 && m.height() > 0.0)
}

impl egui_table::TableDelegate for PinDelegate<'_, '_> {
    fn header_cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::HeaderCellInfo) {
        // 表头在 SplitScroll 里被画**两遍**:调用序 = right_bottom → left_top → right_top
        // → left_bottom(split_scroll.rs:79-151)。`num_sticky_cols = 0` → left_top 那一遍
        // 在零宽象限里:形状画不出(裁剪退化 → tessellator.rs:1367 直接丢),可 egui 的
        // `Rect::intersects` 用 `<=`(零宽 clip 与文字矩形「相切」也算相交)→
        // `is_rect_visible` 判真 → 同一颗表头在 AccessKit 语义树里两颗(0.4.1 实锤:
        // s3/s26/s63/s71 的「成交金额 / 成交号 / 信息 / 最小变动」各两颗)。
        //
        // 判据用**调用序**而不是几何:退化象限里 clip 与格矩形相交时宽恰为 0、不相交时
        // 为负,而「格在视口之外」的 clip 也是负 —— 几何上分不开(本轮实撞:按几何判
        // 要么漏掉 left_top 的第 2 列起,要么把视口外的表头一起砍掉)。left_top 恒在
        // right_top 之前 → 本 pass 里这一列的第一次调用就是退化象限,不画。
        //
        // 第二遍(视口那一层)**照画,视口外的列也画**:首帧列宽量测靠的就是「全列都跑
        // 一遍」(table.rs:633-637 的 `do_full_sizing_pass` → `col_range = 0..len`);
        // 漏掉表头,那一列就只量到体表内容(0.4.1 实锤:「成交金额」列 24px 而表头文字
        // 57px,表头文字压进隔壁列)。视口外的格 `is_rect_visible` 判假 → 不画也不建
        // 语义节点,所以照画不会带回双节点。
        let i = cell.col_range.start;
        let Some(seen) = self.header_drawn.get(i) else {
            return;
        };
        let seen = *seen;
        if !seen {
            self.header_drawn[i] = true;
            return;
        }
        // 表头格:文字(0.4.0 前的形制 == strong + small)+ 可点(排序入口)。
        // 带色底与铺满全宽的带已在 pinned_table_impl 里画好(在表 show 之前 =
        // z 序在下),这里只画文字 + register 点击。
        let i = cell.col_range.start;
        let mark = match self.sort {
            Some((c, asc)) if c == i => {
                if asc {
                    " ▲"
                } else {
                    " ▼"
                }
            }
            _ => "",
        };
        // 表头文字上下居中(0.4.1 七期 BD-UI-60,用户裁定「表格里的文字包括抬头都要上下居中」)。
        // 旧口径:库给的表头格 child ui 起笔在格顶 → 实测带高 29 时文字视觉盒上隙 11 / 下隙 4。
        // 光靠格内起笔调不出来(galley 的**视觉盒**比它的行盒偏下几 px)→ 直接把这一颗文字
        // 按带的垂直中心摆:在 `band_mid ∓ 行高/2` 处开一个与格同宽的子 ui。
        let cell = ui.max_rect();
        let gh = ui.text_style_height(&egui::TextStyle::Small);
        let target = egui::Rect::from_min_size(
            egui::pos2(cell.min.x, self.band_mid - gh * 0.5),
            egui::vec2(cell.width(), gh),
        );
        ui.scope_builder(egui::UiBuilder::new().max_rect(target), |ui| {
            ui.label(
                egui::RichText::new(format!("{}{mark}", self.headers[i]))
                    .strong()
                    .small(),
            );
        });
        // 点表头 = 排序。id 用「这颗表头格自己的 ui id」:egui_table 给表头格的 child ui
        // 带 `id_salt(("header", row_nr, group_index))`(table.rs:586-593)→ 列间唯一、
        // 帧间稳定;旧口径(`ui.id().with(("ek_table_header", table_id, i))`)的等价物。
        let id = ui.id().with(("hdr_click", i));
        if ui
            .interact(ui.max_rect(), id, egui::Sense::click())
            .clicked()
        {
            self.clicked.get_or_insert(i);
        }
    }

    fn row_ui(&mut self, ui: &mut egui::Ui, row_nr: u64) {
        // 左下象限(零宽,`num_sticky_cols = 0`)也会逐行调到这里 —— 与表头同理,
        // 别让它把选中底色 / 行线登记进形状表。
        if quadrant_is_degenerate(ui) {
            return;
        }
        let r = ui.max_rect();
        // **整行**底色,不是「有内容的那几格」(0.4.1 九期 BD-UI-62,用户裁定「我们选的是整行,
        // 包括空行的也一起给它标上颜色」)。库给的行矩形 = `col_x[0]..=col_x[last]`
        // (table.rs:689-690) = 所有列的宽度并集;列总宽窄于可视区时(日志表两列、接管版
        // 列宽的表),行矩形右边就比可视区短一截 → 高亮只到最后一列右边,右边空余不亮。
        // 补法:右边至少铺到「与表头带同宽」(带 = 可视区宽,`band_x` 构造时已捕获)。
        let band_w = self.band_x.max - self.band_x.min;
        let fill = egui::Rect::from_min_max(
            egui::pos2(r.min.x, r.min.y),
            egui::pos2(r.max.x.max(r.min.x + band_w), r.max.y),
        );
        // 选中底色(0.2.29 口径 = 主题 `selection.bg_fill`,不再手绘蓝)。
        if let Some(row) = self.rows.get(row_nr as usize) {
            if row.selected {
                ui.painter()
                    .rect_filled(fill, 0.0, ui.visuals().selection.bg_fill);
            } else if ui.rect_contains_pointer(fill) {
                // 悬停判定也用 `fill`(整行)而不是 `r`(内容宽):否则「行尾空余」这一带
                // 鼠标放进去根本不会亮 —— 那等于只把颜色补宽了,裁定没做全。
                ui.painter()
                    .rect_filled(fill, 0.0, ui.visuals().widgets.hovered.bg_fill);
            }
            // 行线(0.4.1 五期 BD-UI-58:全部表都有,不再是合约表专属)。
            // x 横跨**表体可视宽**而不是这一行的内容宽:旧口径取 TableBuilder body 的
            // `max_rect().x_range()` = 横向 ScrollArea 内层 ui 的可用宽 = 整张表宽;
            // 按单行内容宽画,列内容窄于面板时行线只到最后一列右边,与表头带不同宽
            // (0.4.1 实锤:合约表行线 427..825,带 423..991 → 判据里 13 行数成 1 根)。
            // 行线(0.4.1 五期 BD-UI-58:全部表都有;六期 BD-UI-60 起它是**唯一**的网格线,
            // 列线按用户裁定撤除)。颜色取 delegate 构造时捕获的 `line` —— 表 scope 里
            // `noninteractive.bg_stroke` 已被改成透明(见 `pinned_table_impl`),在这里现取
            // 会连行线一起抹掉。
            ui.painter()
                .hline(self.band_x, r.max.y, egui::Stroke::new(1.0, self.line));
        }
    }

    fn cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::CellInfo) {
        // 每格左内边距 6(注释见模块头);按列取出延迟画笔就地执行(FnMut,
        // 首帧两遍都跑;不 replace —— FnOnce 那套会让第二遍全空,0.4.1 实锤)。
        let Some(row) = self.rows.get_mut(cell.row_nr as usize) else {
            return;
        };
        let Some(slot) = row.cells.get_mut(cell.col_nr) else {
            // 空态行只推了首格(§4.2 低优):缺格静默跳过。
            return;
        };
        // 满格命中层(见 `PinCellCtx::hit`):注册在格这一层 —— 这层的 clip 就是整格,
        // `interact` 的 interact_rect = clip ∩ 整格 = 整格。id 用这层自己的 ui.id():
        // egui_table 给格 child ui 带 `id_salt((row_nr, col_nr))`(table.rs:713-720)
        // → 格间唯一、帧间稳定(旧 `TableRow::col` 那颗格响应的等价物)。
        let hit = ui.interact(
            ui.max_rect(),
            ui.id().with("pin_cell"),
            egui::Sense::click(),
        );
        let ctx = PinCellCtx { hit };
        egui::Frame::new()
            .inner_margin(egui::Margin {
                left: 6,
                right: 6,
                top: 0,
                bottom: 0,
            })
            .show(ui, |ui| slot(ui, &ctx));
    }

    fn default_row_height(&self) -> f32 {
        self.row_h
    }
}

/// pinned_table 用的 delegate(见模块头说明;字段随 `pinned_table_impl` 收集)。
struct PinDelegate<'d, 'b> {
    headers: &'d [&'d str],
    sort: Option<(usize, bool)>,
    rows: &'d mut [PinRow<'b>],
    row_h: f32,
    clicked: Option<usize>,
    /// 本 pass 里每列表头已画过没有(表头在 SplitScroll 里被画两遍,见 `header_cell_ui`)。
    header_drawn: Vec<bool>,
    /// 表头带的 x 范围 = 表体可视宽。行线按它画,不按单行的内容宽(旧口径,见 `row_ui`)。
    line: egui::Color32,
    band_x: egui::Rangef,
    /// 表头带的垂直中心(0.4.1 七期 BD-UI-60:表头文字按它上下居中,见 `header_cell_ui`)。
    band_mid: f32,
}

/// 行高/表头高 = 字号 + 6(`table_heights` 见上;两值直接进 `HeaderRow`/行收集)。
///
/// 四个入口都要带自由 `'p`:行收集物 `PinBody<'p>` 的行延迟画笔寿命 = **调用方帧**
/// (add_body 闭包里的格闭包借用外层局部 —— `'p` 由调用点挑一个盖住它们的生命周期)。
/// 写成匿名的 `PinBody<'_>`(HRTB ⇒ for<'x> 任一寿命)会让借用逃逸出函数体
/// (E0521/E0597,本轮实撞)。
fn pinned_table<'p>(
    ui: &mut egui::Ui,
    table_id: &str,
    headers: &[&str],
    body: impl FnOnce(&mut PinBody<'p>, f32),
) -> Option<usize> {
    pinned_table_impl(ui, table_id, headers, None, body)
}

/// 0.3.1(Wave D,BD-UI-49)可排序表:`sort` = 当前排序键,返回**这一拍被点过的表头列**。
/// 排序键由调用方从 `UiShared::table_sort` 取、点完表头再写回 `toggle_table_sort`.
fn pinned_table_sorted<'p>(
    ui: &mut egui::Ui,
    table_id: &str,
    headers: &[&str],
    sort: Option<(usize, bool)>,
    body: impl FnOnce(&mut PinBody<'p>, f32),
) -> Option<usize> {
    pinned_table_impl(ui, table_id, headers, sort, body)
}

/// pinned_table 的共用实现(0.4.1 BD-UI-55 起换 egui_table 0.7.0):
/// - **表头天生粘顶**(egui_table `headers` 半 sticky row,0.2.6 DIY 覆盖带撤除)。
/// - **横向溢出**:SplitScroll 的右下象限 = 横向滚动区,表头(右象限)与体表同滚不错位,
///   表头带照画全宽(带底画在 show 之前 = z 序最底)。
/// - **列宽**:库的首帧全量量测(do_full_sizing_pass)+ 之后冻结可持续拖/resizable 列。
///   没有「每帧 auto_size_this_frame」:那会让 delegate(连同我们的action闭包)每帧跑两遍
///   —— egui 的 sizing pass = request_discard 重跑一遍帧,行动作一切两半(0.4.1 实查:
///   table.rs:384-443 的 discard 挂 per-column,content 变化重测路径库注释自己也标
///   TODO)。内容超宽的列(日志正文)由调用方传列宽。
/// - **行交互**:egui_table 不给格/行级 Response —— 每格用 `pin_cell` 自己建「满格
///   allocate + 控件响应」的两层判定(与 0.2.29 的旧「格响应+控件响应」等价)。
/// - **选中/悬停**:delegate 的 `row_ui`(行带全宽;旧 TableBuilder 的 expand 缝补
///   不再需要 —— egui_table 行与行零间隙)。
/// - **斑马纹依旧没有**(0.2.27 起跟主题 = visuals.striped = false)。
fn pinned_table_impl<'p>(
    ui: &mut egui::Ui,
    table_id: &str,
    headers: &[&str],
    sort: Option<(usize, bool)>,
    add_body: impl FnOnce(&mut PinBody<'p>, f32),
) -> Option<usize> {
    pinned_table_impl_cols(ui, table_id, headers, sort, None, add_body)
}

/// 空态列宽(0.4.1 五期 BD-UI-58,用户裁定「按表头分配各列比例,等有数据再按数据调整」):
/// 按**表头文字宽的比例**把这一拍的可用宽分掉。表头总宽 > 可用宽时不放大(照内容走横向
/// 滚动,与 Python `ResizeToContents` 同形)。`+12` = 格左右内边距 6+6(`cell_ui` 的
/// `inner_margin`),下限 24 = `Column::range` 下限。
fn header_proportional_widths(ui: &egui::Ui, headers: &[&str], available: f32) -> Vec<f32> {
    let head_font = ui.style().text_styles[&egui::TextStyle::Small].clone();
    let head_w = |s: &str| -> f32 {
        let g = ui.painter().layout_no_wrap(
            s.to_string(),
            head_font.clone(),
            egui::Color32::TRANSPARENT,
        );
        (g.size().x + 12.0).max(24.0)
    };
    fill_proportional(headers.iter().map(|h| head_w(h)).collect(), available)
}

/// 表头份额不足可用宽时按比例放大到刚好填满;够宽(会溢出)时原样返回(走横向滚动)。
fn fill_proportional(mut base: Vec<f32>, available: f32) -> Vec<f32> {
    let sum: f32 = base.iter().sum();
    if sum > 0.0 && sum < available {
        let k = available / sum;
        for w in base.iter_mut() {
            *w *= k;
        }
    }
    base
}

/// 列宽接管版(BD-UI-55 适配层第二入口):内容**逐帧生长**的表(日志正文)由调用方
/// 传列当前宽 —— 字体量串走 egui 的 galley 缓存(同串命中),不走库的
/// auto_size_this_frame(那条路 = request_discard 每帧双 pass,action 闭包跑两遍)。
/// 空态(这一拍没有数据行,由 `n_rows == 0` 现场判定,不用调用方声明)且没传列宽时,
/// 按表头比例分配(见 `header_proportional_widths`)。
pub(crate) fn pinned_table_impl_cols<'p>(
    ui: &mut egui::Ui,
    table_id: &str,
    headers: &[&str],
    sort: Option<(usize, bool)>,
    col_current: Option<Vec<f32>>,
    add_body: impl FnOnce(&mut PinBody<'p>, f32),
) -> Option<usize> {
    let ch = Chrome::of(ui.visuals());
    let (row_h, head_h) = table_heights(ui);
    let sp = ui.spacing().item_spacing;
    let m = ui.visuals().clip_rect_margin;
    // 表头带:整行宽(Python QHeaderView 铺满表视口;0.2.28 给浮动窗短表补的
    // header_band 铺整行是同一件事)。画在表之前 = 在表头文字之下,零布局副作用
    // (0.2.6「资金右多一根竖线」那类 min_rect 被撑宽的病在这里不会再犯)。
    let left = ui.cursor().min.x - sp.x * 0.5;
    let top = ui.cursor().top();
    // 带底 = 表头行底边,egui_table 的表头行高就是 `head_h` 且**体表紧接着开始**
    // (行与行、表头与体表都不留 item_spacing,table.rs:404-411 `header_row_y` 累加
    // `header.height()`)。旧 TableBuilder 在表头与首行之间留了一格 `sp.y`,带底才多画
    // 那 6 px —— 照搬过来带底就压进首行(0.4.1 实锤:带 407..442,首行 436..462,
    // 首行文字 440..458 被带底横线从中间切过)。
    let band = egui::Rect::from_min_max(
        egui::pos2(left, top - m),
        egui::pos2(left + ui.available_width() + sp.x, top + head_h),
    );
    ui.painter().rect_filled(band, 0.0, ch.band_fill);
    // 带底下边线:表头带与体表的分界(Python 的表头带与体表是两种底色,天然有界;
    // 我们只有一根带,补线才有界)。
    ui.painter()
        .hline(band.x_range(), band.max.y, egui::Stroke::new(1.0, ch.line));
    // 滚动条形态:全局一处口径(`tokens::apply` 的 `spacing.scroll`,0.4.1 七期 BD-UI-60
    // 起 5 px 细 + BG4 暗把手)。SplitScroll 内部是标准 ScrollArea,吃同一套 spacing。
    let mut body = PinBody::default();
    add_body(&mut body, row_h);
    let n_rows = body.rows.len() as u64;

    // 列(0.4.1 五期 BD-UI-58,用户裁定):`resizable` **恒开**,两个理由绑在一起 ——
    // ① 列线:库只在 resizable 列上画那根可拖边界线(table.rs:740-749 登记 + 896 画线),
    //    这就是用户要的「竖向线显得整齐」;旧口径只有合约表 + 日志表 + 开了右键开关的
    //    成交表有列线,委托/持仓/资金/行情没有 → 形制不统一。
    // ② 列宽随内容生长:`finish` 的 grow-only 分支(table.rs:840-843)只对 resizable 列
    //    生效,旧口径非 resizable 的表列宽只在**首帧**量一次(首帧空态 → 列宽 = 表头
    //    文字宽,数据进来也不长;BD-UI-56 实测:成交表首帧空态列宽 [63.3, 28.4, 43.4,
    //    …] 喂进「螺纹钢2605 / 71230.5 / 142461.0」再跑三帧,一模一样)。这条缺陷跟着
    //    恒开一起修掉。右键菜单里那个「调整列宽」开关因此失去意义 → 一并退役。
    let mut delegate = PinDelegate {
        headers,
        sort,
        rows: &mut body.rows,
        row_h,
        clicked: None,
        header_drawn: vec![false; headers.len()],
        band_x: band.x_range(),
        line: ch.line,
        band_mid: band.center().y,
    };
    // egui_table 的表 scope **不带 id_salt**:SplitScroll 内部 `ui.new_child()`
    // 的 ScrollArea auto-id 由父 ui 的 child 计数器派生 —— 2×2 各格的表(
    // order_grid 与 log_grid)实测滚出同一个 id(17412…5594)—— 滚动状态互相
    // 污染 → `ScrollArea::show_scroll` 每帧交替翻转 → request_repaint 死循环
    // (kittest max_steps 实锤,本轮 `[scrollflip]` 打点)。所以这里显式
    // scope 一层,把每张表的整个子 ui 树根挂在以 table_id 命名的盐上,
    // 滚动 id 与列宽状态各自独立。
    let _ret = ui.scope_builder(
        egui::UiBuilder::new()
            .id_salt(("pinned_table", table_id))
            // scope 的可用区必须从**当前 cursor** 起,不是从 max_rect 左上角:
            // `Table::show` 的 col_x / 表头行 y 都取 `ui.cursor().min`(table.rs:393-411),
            // 而上面那条表头带是按 cursor 画的 —— 用 `ui.max_rect()` 会让表画到
            // cursor **上方**去,带与表头行错开(0.4.1 实锤 s70:资金表头格 y 568..594,
            // 带却画在 y 597..632 = 差一整行)。
            .max_rect(egui::Rect::from_min_max(ui.cursor().min, ui.max_rect().max)),
        |ui| {
            // 列宽校准(BD-UI-58 立,BD-UI-60 补全):表宽 = 所在区域的 100%。
            // Python 侧交易表是 `resizeSections(ResizeToContents)`(widget.py:646)+ Qt
            // `stretchLastSection` 默认 True(全仓只有 portfoliomanager 的树控件显式关掉它)
            // → 列按内容量,剩余宽由**最后一列**吃掉,表宽恒等于视口宽;列总宽超过视口时
            // Qt 不压缩、出横向滚动条 —— 我们照这个口径,超宽不压。
            // 为什么必须**写进 `TableState`**而不是只传 `Column::new(w)`:库只在状态里
            // 没有这张表时才读 `column.current`(table.rs:370-375),而那一刻恰好就是首帧
            // ——首帧库给每列打上 `auto_size_this_frame`(table.rs:377-379),`finish` 于是
            // 走「按内容收缩」那一支(table.rs:837-839)把种子覆盖掉(0.4.1 五期实测:只传
            // 种子,空态右侧照样空 663 px)。先写状态,`TableState::load` 就不再是 None。
            // 只在「状态里的列宽总和还填不满可用宽」时动手:用户手动拖宽之后不再跟他抢。
            let state_id = egui_table::TableState::id(ui, egui::Id::new(table_id));
            // 目标宽要扣掉这张表自己的滚动条占位(`floating = false` 时 ScrollArea 会真的
            // 留出 bar_width + 内外边距):不扣就会把列总宽做到「比可视区还宽」,最后一列
            // 被推到视口外,egui_table 剔掉视口外的列 → 那一列上的撤单钮 / 时间格直接消失
            // (0.4.1 七期实撞:s11 撤单钮、s38 已摘除标注、w6 成交时间列同因)。
            let sc = &ui.style().spacing.scroll;
            let reserve = if sc.floating {
                0.0
            } else {
                sc.bar_width + sc.bar_inner_margin + sc.bar_outer_margin
            };
            let target = (ui.available_width() - reserve).max(0.0);
            // 补宽**一次只补一次**,不每帧补:库的 `finish` 把列宽写回成 `max(可视内容宽)`,
            // 被剔出视口的列写回的是它这一帧的量测宽 → 每帧补就「补宽 → 更挤 → 更多列被剔 →
            // 存宽更小 → 再补」滚起来(0.4.1 七期实撞:每帧补时 s11/s21/t3/f2/w6 同时丢格)。
            // 判据 = 「区域宽变了」或「存宽变了」(= 数据变了)才重新允许补一次;最后一列最多
            // 吃掉半张表,补不出无限宽的列。
            let fill_id = egui::Id::new(("table_fill", table_id));
            let (last_target, last_stored, filled) = ui.ctx().data(|d| {
                (
                    d.get_temp::<f32>(fill_id).unwrap_or(0.0),
                    d.get_temp::<f32>(fill_id.with("stored")).unwrap_or(0.0),
                    d.get_temp::<bool>(fill_id.with("filled")).unwrap_or(false),
                )
            });
            let mut st = egui_table::TableState::load(ui.ctx(), state_id).unwrap_or_default();
            let stored: f32 = st.col_widths.values().sum();
            let may_fill = !filled
                && ((target - last_target).abs() > 8.0 || (stored - last_stored).abs() > 0.5);
            let mut did_fill = false;
            let mut new_stored = stored;
            if col_current.is_none() && may_fill && stored < target {
                if stored <= 0.0 {
                    // 这一张表还没量过列宽。**只有空态**才在这里按表头比例下种子(BD-UI-58
                    // 口径);有数据时不写,让库在首帧做全量内容量测(`do_full_sizing_pass`)
                    // —— 提前下种子会把「时间」「接口」这类窄表头列压成 26 px,格内文字被
                    // 列宽裁掉(0.4.1 七期实撞:w6 成交时间列、s38 已摘除标注)。
                    if n_rows == 0 {
                        for (i, w) in header_proportional_widths(ui, headers, target)
                            .into_iter()
                            .enumerate()
                        {
                            st.col_widths.insert(egui::Id::new(i), w);
                        }
                        did_fill = true;
                    }
                } else {
                    // 已有列宽(按内容量过):剩余补给最后一列 = stretchLastSection 等价。
                    let last = egui::Id::new(headers.len() - 1);
                    let cur = st.col_widths.get(&last).copied().unwrap_or(24.0);
                    let add = (target - stored).min((target * 0.5 - cur).max(0.0));
                    if add > 0.5 {
                        st.col_widths.insert(last, cur + add);
                        did_fill = true;
                    }
                }
                if did_fill {
                    new_stored = st.col_widths.values().sum::<f32>();
                    st.store(ui.ctx(), state_id);
                }
            }
            ui.ctx().data_mut(|d| {
                d.insert_temp(fill_id, target);
                d.insert_temp(fill_id.with("stored"), new_stored);
                d.insert_temp(fill_id.with("filled"), did_fill || filled);
            });
            let seed: Option<Vec<f32>> = col_current;
            // 列线(0.4.1 七期 BD-UI-60,用户裁定「表格里所有的列分割线都去掉,只要行的分割线」):
            // 库里那根线撤不掉 —— 它只在**可拖列边界**上画(table.rs:738-749 登记 + 896 画线),
            // 而 `resizable` 必须恒开(列宽随内容生长靠它,BD-UI-56 那条缺陷就堵在这)。
            // 做法 = 把它的**常态**描边改成全透明 → 平时看不见;hover / 拖拽仍走
            // `widgets.hovered` / `widgets.active` 的描边 → 拖列宽时那根线亮出来(用户裁定保留)。
            // 只改这一支,不改 `BORDER` 本体:行线、表头带底边线、窗框都另有取色(行线取
            // delegate 里捕获的 `line`)。
            ui.style_mut().visuals.widgets.noninteractive.bg_stroke =
                egui::Stroke::new(1.0, egui::Color32::TRANSPARENT);
            // 列宽从哪来(0.4.1 实查,库侧两条硬事实):
            // ① `Table::show` 只在**首帧**(`TableState::load` 为 None)做全量内容量测
            //    (`do_full_sizing_pass`,table.rs:365-379);
            // ② 量出来的宽度只有 **resizable 列**才会写回 `TableState` —— `finish`
            //    (table.rs:816-843)只遍历 `visible_column_lines`,而这两根线只在
            //    `column.resizable` 时登记(table.rs:614、table.rs:738-749)。
            // 0.4.1 五期(BD-UI-58)起 `resizable` 恒开:①②两条从此**每张表都吃到**
            // (列宽随内容生长 + 每列一根可拖列线),不再需要「首帧临时全标 resizable」
            // 那套补丁。起手宽 = 空态种子(按表头比例)或 100(库随后按内容量测覆盖)。
            let columns: Vec<egui_table::Column> = match &seed {
                Some(ws) => headers
                    .iter()
                    .zip(ws.iter().copied())
                    .enumerate()
                    .map(|(i, (_, w))| {
                        egui_table::Column::new(w)
                            .range(24.0..=f32::INFINITY)
                            .id(egui::Id::new(i))
                            .resizable(true)
                    })
                    .collect(),
                None => headers
                    .iter()
                    .enumerate()
                    .map(|(i, _)| {
                        egui_table::Column::new(100.0)
                            .range(24.0..=f32::INFINITY)
                            .id(egui::Id::new(i))
                            .resizable(true)
                    })
                    .collect(),
            };
            egui_table::Table::new()
                .id_salt(table_id)
                .num_rows(n_rows)
                .columns(columns)
                .headers([egui_table::HeaderRow::new(head_h)])
                .auto_size_mode(egui_table::AutoSizeMode::Never)
                .show(ui, &mut delegate);
        },
    );
    delegate.clicked
}
/// 为什么要这个:egui 的点击只发给**一个**响应 —— `context.rs:1384`
/// `let clicked = Some(id) == viewport.interact_widgets.clicked;` 比的是单个 Id,
/// 后加的控件优先。格里有真控件(钮 / 勾选框)时,点击归那个控件,归不到
/// `TableRow::col` 返回的「格响应」。所以行动作必须两边都看(0.2.29 实锤:
/// 只看格响应 → 行情首列钮、委托撤单钮、合约勾选框三处点击全丢)。
/// `pin_cell` 是 pub(crate)(kittest 判据要用),所以本 trait 也要 pub(crate)
/// (否则 clippy private-trait 警报)。
pub(crate) trait CellOutcome {
    fn response(&self) -> Option<egui::Response>;
}

impl CellOutcome for egui::Response {
    fn response(&self) -> Option<egui::Response> {
        Some(self.clone())
    }
}

impl CellOutcome for () {
    fn response(&self) -> Option<egui::Response> {
        None
    }
}

impl<T: CellOutcome> CellOutcome for egui::InnerResponse<T> {
    fn response(&self) -> Option<egui::Response> {
        self.inner
            .response()
            .or_else(|| Some(self.response.clone()))
    }
}

/// 喂表前的快照副本排序(W2 的判据对象)。`key` 返回**可比较字符串**;数字列调用方给
/// 零填充串(`{:020.4}`)保证字典序 == 数值序。`sort` 为 `None` = 原序,一行都不动。
fn sorted_rows<'a, T>(
    rows: &'a [T],
    sort: Option<(usize, bool)>,
    key: impl Fn(&T, usize) -> String,
) -> Vec<&'a T> {
    let mut out: Vec<&'a T> = rows.iter().collect();
    if let Some((col, asc)) = sort {
        out.sort_by(|a, b| {
            let (ka, kb) = (key(a, col), key(b, col));
            if asc { ka.cmp(&kb) } else { kb.cmp(&ka) }
        });
    }
    out
}

/// 成交行的展示字符串(`TRADE_HEADERS` 同序)。**排序键与「保存数据」CSV 共用这一份口径** ——
/// 表里看见什么,导出来就是什么,不留第二套格式化。
/// 0.3.3 Wave F:这一份的顺序就是 `TRADE_HEADERS` 的顺序(判据 F1 钉「内容与表头同列」,
/// 两处不同序 = CSV 与屏幕不一致)。
fn trade_cells(t: &ek_data::trade::TradeData) -> Vec<String> {
    vec![
        t.symbol.clone(),
        if t.name.is_empty() {
            "-".to_string()
        } else {
            t.name.clone()
        },
        t.exchange.as_str().to_string(),
        t.direction
            .map(|d| d.as_str().to_string())
            .unwrap_or_else(|| "-".to_string()),
        t.offset.as_str().to_string(),
        crate::format::price(t.price),
        crate::format::volume(t.volume),
        crate::format::price(t.traded_amount),
        crate::format::time_hms_millis(t.datetime),
        t.tradeid.clone(),
        t.orderid.clone(),
        t.gateway_name.clone(),
    ]
}

/// 排序键:数字列给**零填充串**(`{:020.4}`)保证字典序 == 数值序;文本列直接比。
/// 注意不能直接拿 `trade_cells` 的显示串当排序键 —— "101" < "12.345" < "3.5" 是字典序,
/// 不是价格序(本轮实撞,判据 W1 当场红出来的)。
/// 列序 0.3.3 Wave F 跟着 `TRADE_HEADERS` 走:5 = 价格,6 = 数量,7 = 成交金额,8 = 时间。
fn trade_sort_key(t: &ek_data::trade::TradeData, col: usize) -> String {
    match col {
        5 => format!("{:020.4}", t.price),
        6 => format!("{:020.4}", t.volume),
        7 => format!("{:020.4}", t.traded_amount),
        8 => t.datetime.map(|ns| format!("{ns:025}")).unwrap_or_default(),
        c => trade_cells(t).get(c).cloned().unwrap_or_default(),
    }
}

/// 「保存数据」:当前表(已按界面排序键排好的那份副本)写成 CSV 到 `target/ek-ui-<表>.csv`。
/// 只写界面看见的内容,不碰后端、不碰数据模型。
fn save_table_csv<T>(
    table: &str,
    headers: &[&str],
    rows: &[&T],
    cells: impl Fn(&T) -> Vec<String>,
) -> std::io::Result<()> {
    let mut s = headers.join(",");
    s.push('\n');
    for r in rows {
        s.push_str(&cells(r).join(","));
        s.push('\n');
    }
    let dir = std::env::current_dir()?.join("target");
    std::fs::create_dir_all(&dir)?;
    std::fs::write(dir.join(format!("ek-ui-{table}.csv")), s)
}

// ================= 菜单栏 =================

fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
    ui.horizontal(|ui| {
        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
        if ui.button(MENU_OPS).clicked() {
            shared.set_ops_window_open(true);
        }
        if ui.button(MENU_ACCOUNTS).clicked() {
            shared.set_accounts_window_open(true);
        }
        if ui.button(MENU_CONTRACTS).clicked() {
            shared.set_contracts_window_open(true);
        }
        // 0.3.0(Wave C,BD-UI-48)→ 0.4.1(BD-UI-54):布局可复位 —— 一键回
        // Python 框架等价(分刀份额 50:50 + 三个 tab 态回缺省;行情带/下单侧两块
        // 顶层面板的记忆尺寸一并清掉 = 下帧回 default_height/default_width)。
        if ui.button("恢复布局").clicked() {
            shared.reset_layout();
            ui.ctx().data_mut(|d| {
                d.remove::<egui::containers::panel::PanelState>(egui::Id::new("market_band"));
                d.remove::<egui::containers::panel::PanelState>(egui::Id::new("order_side"));
            });
        }
        ui.menu_button(MENU_HELP, |ui| {
            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
        });
        // 0.2.30(裁定 8,BD-UI-46):主题一键循环钮删除。界面只有一套 Qt深色
        // (qdarkstyle 3.2.3 复刻),「可换主题」这件事本身从产品面上撤掉了。
    });
}

// ================= 行情格 =================

/// 行情表头(14 列,与 `tick_sort_key` 同序)。
const TICK_HEADERS: [&str; 14] = [
    "代码",
    "名称",
    "最新价",
    "涨跌幅",
    "成交量",
    "今开",
    "最高",
    "最低",
    "买1价",
    "买1量",
    "卖1价",
    "卖1量",
    "时间",
    "行情源",
];

/// 行情格:一个交易所一格(0.4.0)。组内那条「按所二级 tab」条已撤除 —— 所的身份由
/// dock tab 承担(该格只画本所的行;哪一所在前台由 dock 树记)。
pub(crate) fn render_market_pane(
    ui: &mut egui::Ui,
    shared: &UiShared,
    ex: ek_data::constant::Exchange,
) {
    // 表状态按格分开:6 个所的行情格各有各的排序与列宽(同一个 id 会被 egui 当重复)。
    let table_id = format!("tick_{}", ex.as_str());
    let groups = shared.tick_groups();
    let ticks: Vec<ek_data::tick::TickData> = groups
        .iter()
        .find(|(e, _)| e == ex.as_str())
        .map(|(_, v)| v.clone())
        .unwrap_or_default();
    // 空态口径(0.4.1 五期 BD-UI-58 + 六期 BD-UI-59 两次裁定合起来):表头恒在(s73 钉),但**不画那一行
    // 「(无数据)」提示** —— 表体就是空的,用户看得见;列宽这一拍按表头比例填满(s74 钉)。
    // 「这一拍没有数据行」由 `pinned_table_impl_cols` 现场数 `n_rows == 0` 判定,
    // 调用方不再分支:空与非空走同一条路,body 收出 0 行就是空表。
    // (0.4.0 那条「空态不得吞掉表头」的修正仍然成立:这里原来直接 `muted_label + return`,
    // 整张表含表头都不画,六个行情格在没有订阅时全是光秃秃一句提示。Python 也是空表照画表头。)
    // 0.3.3(收 Wave D 欠账):行情格也吃「点表头排序」(缺省序 = 按所/symbol)。
    let sort = shared.table_sort(&table_id);
    let ticks = sorted_rows(&ticks, sort, tick_sort_key);
    let clicked = pinned_table_sorted(ui, &table_id, &TICK_HEADERS, sort, |body, row_h| {
        const RIGHT_COLS: [usize; 10] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
        for t in &ticks {
            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
            // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
            // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
            // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
            let row_sel = shared.order_form().symbol == t.symbol;
            body.row(row_h, row_sel, |r| {
                // 延迟画笔(BD-UI-55):格内容在体表真实绘制时跑;点击/双击与
                // 行动作的判定照旧在「格响应 || 控件响应」两边看,BD-UI-55 实查:
                // egui_table 每帧只跑一遍(不用 auto_size_this_frame,那会双跑)。
                // 闭包不 move(借用 shared / t / key —— 它们都是外层的只读局部,
                // 出不了 pinned_table_impl 的调用帧,借给 'b 完全合法)。
                macro_rules! cell {
                    ($make:expr, $col:expr) => {{
                        r.cell(|ui, ctx| {
                            let (cell_r, inner_r) =
                                pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
                            let hit =
                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
                            if hit {
                                // import_tick(用户裁定:0.4.1 行单击不再切交易所 ——
                                // 本格已是该所;这里只把合约带进下单面板)。
                                import_tick(shared, t, ex.as_str(), $col);
                            }
                            let dbl = cell_r.double_clicked()
                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
                            if dbl {
                                shared.open_kline(t.symbol.clone());
                            }
                        });
                    }};
                }
                cell!(|ui| ui.button(t.symbol.clone()), 0);
                cell!(
                    |ui| ui.label(if t.name.is_empty() {
                        "-".to_string()
                    } else {
                        t.name.clone()
                    }),
                    1

                );
                cell!(|ui| ui.label(crate::format::price(t.last_price)), 2);
                cell!(
                    |ui| ui.label(
                        egui::RichText::new(crate::format::pct(t.pct_change))
                            .color(crate::tokens::rise_fall(t.pct_change)),
                    ),
                    3

                );
                cell!(|ui| ui.label(crate::format::volume(t.volume)), 4);
                cell!(|ui| ui.label(crate::format::price(t.open_price)), 5);
                cell!(|ui| ui.label(crate::format::price(t.high_price)), 6);
                cell!(|ui| ui.label(crate::format::price(t.low_price)), 7);
                cell!(
                    |ui| ui.label(
                        egui::RichText::new(crate::format::price(t.bid_price_1))
                            .color(crate::tokens::semantic::RISE),
                    ),
                    8

                );
                cell!(
                    |ui| ui.label(
                        egui::RichText::new(crate::format::volume(t.bid_volume_1))
                            .color(crate::tokens::semantic::RISE),
                    ),
                    9

                );
                cell!(
                    |ui| ui.label(
                        egui::RichText::new(crate::format::price(t.ask_price_1))
                            .color(crate::tokens::semantic::FALL),
                    ),
                    10

                );
                cell!(
                    |ui| ui.label(
                        egui::RichText::new(crate::format::volume(t.ask_volume_1))
                            .color(crate::tokens::semantic::FALL),
                    ),
                    11

                );
                cell!(
                    |ui| ui.label(if t.update_time.is_empty() {
                        crate::format::time_hms_millis(Some(t.datetime))
                    } else {
                        t.update_time.clone()
                    }),
                    12

                );
                cell!(|ui| ui.label(t.gateway_name.clone()), 13);
            });
        }
    });
    if let Some(col) = clicked {
        shared.toggle_table_sort(&table_id, col);
    }
}

/// 行情行的排序键(列序 == 行情带 14 列):数字列零填充,时间列纳秒零填充。
fn tick_sort_key(t: &ek_data::tick::TickData, col: usize) -> String {
    match col {
        0 => t.symbol.clone(),
        1 => t.name.clone(),
        2 => format!("{:020.4}", t.last_price),
        3 => format!("{:020.4}", t.pct_change),
        4 => format!("{:020.4}", t.volume),
        5 => format!("{:020.4}", t.open_price),
        6 => format!("{:020.4}", t.high_price),
        7 => format!("{:020.4}", t.low_price),
        8 => format!("{:020.4}", t.bid_price_1),
        9 => format!("{:020.4}", t.bid_volume_1),
        10 => format!("{:020.4}", t.ask_price_1),
        11 => format!("{:020.4}", t.ask_volume_1),
        12 => {
            if t.update_time.is_empty() {
                format!("{:025}", t.datetime)
            } else {
                t.update_time.clone()
            }
        }
        13 => t.gateway_name.clone(),
        _ => String::new(),
    }
}

// ================= 下单面板(常驻;0.4.1 BD-UI-54 回原生 SidePanel) =================

fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
    let mut f = shared.order_form();
    let before = f.clone();
    let mut err: Option<String> = None;

    // 随价勾选:价格框随该合约最新 tick(勾选态)
    if f.follow_price
        && let Some(t) = tick_of(shared, &f.exchange, &f.symbol)
        && t.last_price > 0.0
    {
        f.price = crate::format::price(t.last_price);
    }

    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
    let mut ex_opts: Vec<String> = shared
        .tick_groups()
        .iter()
        .map(|(e, _)| e.clone())
        .collect();
    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
        if !ex_opts.iter().any(|x| x == e) {
            ex_opts.push(e.to_string());
        }
    }
    if !ex_opts.contains(&f.exchange) {
        ex_opts.insert(0, f.exchange.clone());
    }
    // 接口下拉:账户清册(注册序)∪ trade_snap 网关去重集(B3 退役:
    // 不再硬编码 `["ctp"]`;0.2.10 BD-UI-38 sim 纸面网关退场同口径)。
    let ts = shared.trade_snap();
    let mut gw_opts: Vec<String> = Vec::new();
    // 0.2.18(规划 Q4):下单归属只列**已装配**账户 —— 只添加了还没连接的账户
    // 进了下拉,选它下单只会撞 `unknown:gateway`。
    // 账本侧历史网关名的口径(执行核查补的洞):清册非空时,**不在清册的历史网关
    // = 已摘除账户**,它留在下拉里同样是选它下单撞 `unknown:gateway` → 不进下拉
    // (撤单不受影响:撤单归属按每笔单自己的网关走,与这里的下拉无关)。
    // 清册为空 = 不干预(纯账本巡回钉 s27/s14 那类无账户配置面的形)。
    let known_gws = account_gateways(shared);
    for g in shared
        .accounts()
        .iter()
        .filter(|a| a.connected)
        .map(|a| a.gateway.clone())
        .chain(
            ts.orders
                .iter()
                .map(|o| o.gateway_name.clone())
                .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
                .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
                .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
        )
    {
        if !g.is_empty() && !gw_opts.contains(&g) {
            gw_opts.push(g);
        }
    }
    if !gw_opts.contains(&f.gateway)
        && !f.gateway.is_empty()
        && (known_gws.is_empty() || known_gws.contains(&f.gateway))
    {
        gw_opts.insert(0, f.gateway.clone());
    }
    // 手动选过的网关若已不在可选面(被摘除 / 未装配)= 就地回落到默认阶梯:
    // 否则下拉里挂着一个注定 `unknown:gateway` 的选项,且它会成为下一笔单的归属。
    if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
        f.gateway = shared.default_gateway().unwrap_or_default();
    }
    // 表单网关位为空 = 未手动选过:回填默认账户阶梯(上次选择 → 首个已连接 → 首个)。
    // 只改本地 `f` 副本 —— 面板末尾的 `f != before` 写回负责落盘,不在这里重复写。
    if f.gateway.is_empty()
        && let Some(def) = shared.default_gateway()
    {
        f.gateway = def;
    }
    let no_accounts = gw_opts.is_empty();

    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
    egui::Grid::new("order_form_grid")
        .num_columns(2)
        .show(ui, |ui| {
            ui.label("交易所");
            egui::ComboBox::from_id_salt("of_exchange")
                .selected_text(f.exchange.clone())
                .show_ui(ui, |ui| {
                    for e in &ex_opts {
                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
                    }
                });
            ui.end_row();
            ui.label("代码");
            let sym_resp = ui.add(
                egui::TextEdit::singleline(&mut f.symbol)
                    .hint_text("cu2709")
                    .desired_width(150.0),
            );
            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
            // has_focus 口径回车订阅从不触发)。
            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
                let s = f.symbol.trim().to_string();
                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
                let bare = match s.split_once('.') {
                    Some((_, b)) => b.trim().to_string(),
                    None => s,
                };
                if !bare.is_empty() {
                    f.symbol = bare.clone();
                    // 订阅一律路由到行情元账号(规划 §5.C:单源;从账号无 MD 会话,
                    // 发给它只会静默成功 = 观测黑洞,所以路由在界面侧定死)。
                    match subscribe_target(shared) {
                        Ok(gw) => {
                            shared.push_cmd(UiCmd::CtpSubscribe {
                                gateway: gw.clone(),
                                symbols: vec![bare.clone()],
                            });
                            shared
                                .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
                        }
                        Err(e) => shared.log_line(format!("[订阅] {e}")),
                    }
                }
            }
            ui.end_row();
            ui.label("名称");
            ui.label(
                tick_of(shared, &f.exchange, &f.symbol)
                    .map(|t| {
                        if t.name.is_empty() {
                            "-".to_string()
                        } else {
                            t.name
                        }
                    })
                    .unwrap_or_else(|| "-".to_string()),
            );
            ui.end_row();
            ui.label("方向");
            egui::ComboBox::from_id_salt("of_direction")
                .selected_text(f.direction.as_str())
                .show_ui(ui, |ui| {
                    for d in [Direction::LONG, Direction::SHORT] {
                        ui.selectable_value(&mut f.direction, d, d.as_str());
                    }
                });
            ui.end_row();
            ui.label("开平");
            ui.horizontal(|ui| {
                egui::ComboBox::from_id_salt("of_offset")
                    .selected_text(f.offset.as_str())
                    .show_ui(ui, |ui| {
                        for o in [
                            Offset::OPEN,
                            Offset::CLOSE,
                            Offset::CLOSETODAY,
                            Offset::CLOSEYESTERDAY,
                        ] {
                            ui.selectable_value(&mut f.offset, o, o.as_str());
                        }
                    });
                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
                ui.checkbox(&mut f.auto_split, "自动开平");
            });
            ui.end_row();
            ui.label("类型");
            egui::ComboBox::from_id_salt("of_order_type")
                .selected_text(f.order_type.as_str())
                .show_ui(ui, |ui| {
                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
                    }
                });
            ui.end_row();
            ui.label("价格");
            ui.horizontal(|ui| {
                ui.add(
                    egui::TextEdit::singleline(&mut f.price)
                        .hint_text("107660")
                        .desired_width(90.0),
                );
                ui.checkbox(&mut f.follow_price, "随价");
            });
            ui.end_row();
            ui.label("数量");
            ui.add(
                egui::TextEdit::singleline(&mut f.volume)
                    .hint_text("1")
                    .desired_width(90.0),
            );
            ui.end_row();
            ui.label("接口");
            egui::ComboBox::from_id_salt("of_gateway")
                .selected_text(if f.gateway.is_empty() {
                    "(未选账户)".to_string()
                } else {
                    f.gateway.clone()
                })
                .show_ui(ui, |ui| {
                    for g in &gw_opts {
                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
                    }
                });
            ui.end_row();
            ui.label("操作");
            ui.horizontal(|ui| {
                // 无账户 = 无网关可下单(旧「空 → 字面量 ctp」兜底已退役,B4)。
                let send = ui
                    .add_enabled_ui(!no_accounts, |ui| {
                        crate::ui_ext::primary_button(ui, BTN_SEND_ORDER)
                    })
                    .inner
                    .clicked();
                if send {
                    match f.to_request() {
                        Ok(req) => {
                            let fallback = shared.default_gateway();
                            match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
                                Ok(gateway) => {
                                    shared.push_cmd(UiCmd::SendOrder {
                                        req,
                                        gateway,
                                        mode: if f.auto_split {
                                            ek_oms::LockMode::Auto
                                        } else {
                                            ek_oms::LockMode::None
                                        },
                                    });
                                    shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
                                }
                                Err(e) => {
                                    shared.log_line(format!("[下单] {e}"));
                                    err = Some(e);
                                }
                            }
                        }
                        Err(e) => {
                            shared.log_line(format!("[下单] {e}"));
                            err = Some(e);
                        }
                    }
                }
                if ui.button(BTN_CANCEL_ALL).clicked() {
                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
                    let mut n = 0usize;
                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
                        // 撤单跟着**这笔单自己的网关**走(多账号面:不再回落 ctp)。
                        let gw = if !o.gateway_name.is_empty() {
                            o.gateway_name.clone()
                        } else {
                            shared.default_gateway().unwrap_or_default()
                        };
                        if gw.is_empty() {
                            continue;
                        }
                        shared.push_cmd(UiCmd::CancelOrder {
                            orderid: o.orderid.clone(),
                            gateway: gw,
                        });
                        n += 1;
                    }
                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
                }
            });
            ui.end_row();
            crate::ui_ext::muted_label(ui, "代码回车 = 订阅");
            ui.label("");
            ui.end_row();
        });
    if let Some(e) = err {
        ui.label(egui::RichText::new(e).color(crate::tokens::semantic::ERROR));
    }
    if no_accounts {
        // 侧栏底部独立行(不落钮行内:会把「全撤」推出侧栏宽域,s3 结构钉)。
        ui.label(
            egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
                .color(crate::tokens::semantic::WARN),
        );
    }
    if f != before {
        shared.set_order_form(f.clone());
    }
}

/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
/// 行情带跳色行指认,盘口帧不重复报幕)。
/// 盘口买 / 卖侧色。**仍是硬编码**(§7.1 记的两处:卖 `0x2E9E2E` / 买 `0xE05050`),
/// Wave B 的设计令牌层接管后这两支并进语义色,这里先集中成常量免得散落。
const BOOK_ASK_COLOR: egui::Color32 = crate::tokens::semantic::FALL;
const BOOK_BID_COLOR: egui::Color32 = crate::tokens::semantic::RISE;

/// 盘口一行 = 三格:档位标签 / 价 / 量(BD-UI-55:egui_extras 退役,盘口换
/// `egui::Grid` 三列 —— 它只是一块自适应小表,没有表头/粘顶/角色,够不上
/// egui_table 的 delegate 拉动化;价与量右对齐仍走 `num_cell`)。
fn book_row(row: &mut egui::Ui, tag: String, color: egui::Color32, price: f64, volume: f64) {
    row.label(egui::RichText::new(tag).color(color));
    let _ = crate::ui_ext::num_cell(row, |ui| {
        ui.label(egui::RichText::new(crate::format::price(price)).color(color))
    });
    let _ = crate::ui_ext::num_cell(row, |ui| {
        ui.label(egui::RichText::new(crate::format::volume(volume)).color(color))
    });
}

fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
    let f = shared.order_form();
    let Some(t) = tick_of(shared, &f.exchange, &f.symbol) else {
        crate::ui_ext::muted_label(ui, "(无 tick)");
        return;
    };
    // 0.2.29 Wave A 尾巴(BD-UI-45,毛病 6 的本体):五档盘口原来是一支文本
    // `ui.label("卖5  101.5  3")` —— 价与量靠**空格**对齐,位数一变(101.5 → 12345.5)
    // 整块就错位、贴左缘。换成三列小表(档 / 价 / 量),价与量右对齐成列。
    // 这里**不**用 `pinned_table`:下单侧是窄列 + 外层已有 ScrollArea,`pinned_table`
    // 的 `auto_shrink([false, false])` 会吃满整块可用高(0.2.17 s39 那条教训同型)。
    // 表头也不加:Python 侧没有五档盘口这个块(只有行情表的买1价/买1量/卖1价/卖1量
    // 四列),这是我们自加的常驻盘口,保持无表头。
    let (_, _head_h) = table_heights(ui);
    let asks: [(f64, f64); 5] = [
        (t.ask_price_5, t.ask_volume_5),
        (t.ask_price_4, t.ask_volume_4),
        (t.ask_price_3, t.ask_volume_3),
        (t.ask_price_2, t.ask_volume_2),
        (t.ask_price_1, t.ask_volume_1),
    ];
    let bids: [(f64, f64); 5] = [
        (t.bid_price_1, t.bid_volume_1),
        (t.bid_price_2, t.bid_volume_2),
        (t.bid_price_3, t.bid_volume_3),
        (t.bid_price_4, t.bid_volume_4),
        (t.bid_price_5, t.bid_volume_5),
    ];
    // `num_columns` 会让**最后一列吃掉父 ui 剩下的宽度**(egui grid.rs:144-154 +
    // Grid::num_columns 的注释)→ 「量」贴到面板右缘,价与量之间空出一大截。旧口径是
    // TableBuilder 三列 `Column::auto()`(列宽 = 内容)→ 这里不给列数,列宽回到内容驱动
    // (0.4.1 实锤 t8:十行里只有 3 个量格还在 x<280 的盘口块里)。
    egui::Grid::new("book_grid")
        .spacing([4.0, 0.0])
        .show(ui, |grid| {
            for (k, (p, v)) in asks.iter().enumerate() {
                book_row(grid, format!("卖{}", 5 - k), BOOK_ASK_COLOR, *p, *v);
                grid.end_row();
            }
            for (k, (p, v)) in bids.iter().enumerate() {
                book_row(grid, format!("买{}", k + 1), BOOK_BID_COLOR, *p, *v);
                grid.end_row();
            }
        });
}

// ================= 数据格(2×2;0.4.1 回原生:tab 条在 render_topleft_group 等) =================

/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
/// 活动行撤单钮 + 双击撤单,终态行只读)。
fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
    let ts = shared.trade_snap();
    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
    let known_gws = account_gateways(shared);
    // 表题撤除(第五轮审计):格身份就是 dock tab,再挂同名 label = 三重撞名
    // (下单钮/tab/表题),纯冗余。
    let rows: Vec<_> = ts
        .orders
        .iter()
        .filter(|o| !active_only || order_active(o.status))
        .collect();
    // 委托表列头单源(空/非空两分支同源,0.2.7 审计口径)。
    const ORDER_HEADERS: [&str; 17] = [
        "代码",
        "名称",
        "交易所",
        "类型",
        "方向",
        "开平",
        "价格",
        "总量",
        "已成",
        "成交均价",
        "状态",
        "状态描述",
        "时间",
        "委托号",
        "来源",
        "接口",
        "操作",
    ];
    // 0.3.3(收 Wave D 欠账):委托表也吃「点表头排序」—— 表 id 先算出来,
    // 空态与非空态共用一份(排序键住在 UiShared,按表 id 分槽)。
    let table_id = if active_only {
        "active_grid"
    } else {
        "order_grid"
    };
    let sort = shared.table_sort(table_id);
    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59):body 收出 0 行 = 空表,
    // 列宽由 `pinned_table_impl_cols` 按表头比例填满。
    // 排序只作用在「喂表前的快照副本」上(W2 口径,数据模型不被界面排序改写)。
    let rows = sorted_rows(&rows, sort, |o, col| order_sort_key(o, col));
    let clicked = pinned_table_sorted(ui, table_id, &ORDER_HEADERS, sort, |body, row_h| {
        const RIGHT_COLS: [usize; 4] = [6, 7, 8, 9];
        // move 格闭包要把这几样所有权收进去 —— 预先用引用整形(move 闭包拷贝引用,
        // 不搬 Vec;known_gws/rows 都活到调用帧外,E0382/E0505 实撞)。
        let known_gws = &known_gws;
        for o in &rows {
            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
            // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
            // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
            let key = format!("{}.{}", o.gateway_name, o.orderid);
            let row_sel = shared.row_selected("orders", &key);
            body.row(row_h, row_sel, |r| {
                macro_rules! cell {
                    ($make:expr, $col:expr) => {{
                        // key 是**行内局部**:格延迟画笔要活到 show,必须自带 key 的
                        // 所有权(每格一份克隆;借用会随 add_body 帧悬空,E0373 实撞)。
                        let key = key.clone();
                        r.cell(move |ui, ctx| {
                            let (cell_r, inner_r) =
                                pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
                            let hit =
                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
                            if hit {
                                shared.select_row("orders", &key);
                            }
                            let dbl = cell_r.double_clicked()
                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
                            if dbl && order_active(o.status) {
                                // 双击撤单同按钮口径:跟着这笔单自己的网关(空串回落默认账户阶梯)。
                                let gw = if !o.gateway_name.is_empty() {
                                    o.gateway_name.clone()
                                } else {
                                    shared.default_gateway().unwrap_or_default()
                                };
                                if !gw.is_empty() {
                                    shared.push_cmd(UiCmd::CancelOrder {
                                        orderid: o.orderid.clone(),
                                        gateway: gw,
                                    });
                                }
                            }
                        });
                    }};
                }
                cell!(|ui| ui.label(o.symbol.clone()), 0);
                cell!(
                    |ui| ui.label(if o.name.is_empty() {
                        "-".to_string()
                    } else {
                        o.name.clone()
                    }),
                    1

                );
                cell!(|ui| ui.label(o.exchange.as_str().to_string()), 2);
                cell!(|ui| ui.label(o.order_type.as_str().to_string()), 3);
                cell!(
                    |ui| ui.label(
                        egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
                            if o.direction == Some(Direction::SHORT) {
                                crate::tokens::semantic::FALL
                            } else {
                                crate::tokens::semantic::RISE
                            },
                        ),
                    ),
                    4

                );
                cell!(|ui| ui.label(o.offset.as_str().to_string()), 5);
                cell!(|ui| ui.label(crate::format::price(o.price)), 6);
                cell!(|ui| ui.label(crate::format::volume(o.volume)), 7);
                cell!(|ui| ui.label(crate::format::volume(o.traded)), 8);
                cell!(|ui| ui.label(crate::format::price(o.traded_price)), 9);
                cell!(
                    |ui| ui.label(egui::RichText::new(o.status.as_str()).color(
                        if o.status == ek_data::constant::Status::REJECTED {
                            crate::tokens::semantic::RISE
                        } else if o.status == ek_data::constant::Status::ALLTRADED {
                            crate::tokens::semantic::FALL
                        } else {
                            crate::tokens::semantic::WARN
                        },
                    )),
                    10

                );
                cell!(|ui| ui.label(o.status_msg.clone()), 11);
                cell!(
                    |ui| ui.label(crate::format::time_hms_millis(o.datetime)),
                    12

                );
                cell!(|ui| ui.label(o.orderid.clone()), 13);
                cell!(
                    |ui| ui.label(if o.reference.is_empty() {
                        "-".to_string()
                    } else {
                        o.reference.clone()
                    }),
                    14

                );
                cell!(
                    |ui| ui.label(ledger_gw_label(&o.gateway_name, known_gws)),
                    15

                );
                // 「操作」列 = 活动行的撤单钮(延迟画笔:借用 o/key,出不了调用帧)。
                r.cell(|ui, _ctx| {
                    ui.horizontal(|ui| {
                        if order_active(o.status)
                            && ui
                                .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
                                .clicked()
                        {
                            // 撤单跟随委托的来路网关(行级 gateway_name;
                            // 多账号面:空串回落默认账户阶梯,不再硬编码 ctp)
                            let gw = if !o.gateway_name.is_empty() {
                                o.gateway_name.clone()
                            } else {
                                shared.default_gateway().unwrap_or_default()
                            };
                            if gw.is_empty() {
                                shared.log_line(format!(
                                    "[撤单] {} 无网关可撤(账户清册空)",
                                    o.orderid
                                ));
                            } else {
                                shared.push_cmd(UiCmd::CancelOrder {
                                    orderid: o.orderid.clone(),
                                    gateway: gw,
                                });
                            }
                        }
                    });
                });
            });
        }
    });
    if let Some(col) = clicked {
        shared.toggle_table_sort(table_id, col);
    }
}

/// 委托行的排序键(列序 == `ORDER_HEADERS`):数字列给零填充串保证字典序 == 数值序
/// (`价格/总量/已成/成交均价`),时间列给纳秒零填充,其余给该列的文本。
/// 0.3.3 收 Wave D 欠账时新增 —— 与成交表的 `trade_sort_key` 同一口径。
fn order_sort_key(o: &ek_data::order::OrderData, col: usize) -> String {
    match col {
        0 => o.symbol.clone(),
        1 => o.name.clone(),
        2 => o.exchange.as_str().to_string(),
        3 => o.order_type.as_str().to_string(),
        4 => o
            .direction
            .map(|d| d.as_str().to_string())
            .unwrap_or_default(),
        5 => o.offset.as_str().to_string(),
        6 => format!("{:020.4}", o.price),
        7 => format!("{:020.4}", o.volume),
        8 => format!("{:020.4}", o.traded),
        9 => format!("{:020.4}", o.traded_price),
        10 => o.status.as_str().to_string(),
        11 => o.status_msg.clone(),
        12 => o.datetime.map(|ns| format!("{ns:025}")).unwrap_or_default(),
        13 => o.orderid.clone(),
        14 => o.reference.clone(),
        15 => o.gateway_name.clone(),
        // 第 17 列是「操作」= 撤单钮,没有可排的数据 → 空串(点它排序等于不动)。
        _ => String::new(),
    }
}

/// 成交格(12 列逐值 = Python TradeMonitor 蓝图)。
pub(crate) fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
    // 单 tab(Python `tab_widget2.addTab(trade_widget, "成交")`:那区也是 QTabWidget,
    // 只有一个 tab 也带 tab 头 —— BD-UI-41 补齐;0.4.0 删过,0.4.1 回归原生后恢复)。
    tab_label(ui, true, "成交");
    let ts = shared.trade_snap();
    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
    let known_gws = account_gateways(shared);
    // 成交表列头单源(空/非空两分支同源,0.2.7 审计口径)。
    // 0.3.3 Wave F(BD-UI-51):列序照 Python `TradeMonitor.headers`(widget.py:1270-1283)——
    // 代码在首列,成交号在第 10 列;原来我们把「成交号」放首列,是差集里唯一一条纯 UI 差集。
    // 列名「成交额」→「成交金额」同 Python。
    const TRADE_HEADERS: [&str; 12] = [
        "代码",
        "名称",
        "交易所",
        "方向",
        "开平",
        "价格",
        "数量",
        "成交金额",
        "时间",
        "成交号",
        "委托号",
        "接口",
    ];
    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59,同未成交口径)。
    let sort = shared.table_sort("trade_grid");
    let rows = sorted_rows(&ts.trades, sort, trade_sort_key);
    let clicked = pinned_table_sorted(ui, "trade_grid", &TRADE_HEADERS, sort, |body, row_h| {
        const RIGHT_COLS: [usize; 3] = [5, 6, 7];
        // move 格闭包要把这几样所有权收进去 —— 预先引用整形(同委托表口径)。
        let rows = &rows;
        let known_gws = &known_gws;
        for t in rows.iter().copied() {
            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
            // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
            let key = format!("{}.{}", t.gateway_name, t.tradeid);
            let row_sel = shared.row_selected("trades", &key);
            body.row(row_h, row_sel, |r| {
                macro_rules! cell {
                    ($make:expr, $col:expr) => {{
                        // key 是行内局部:格延迟画笔要活到 show,每格自备克隆(见委托表)。
                        let key = key.clone();
                        r.cell(move |ui, ctx| {
                            let (cell_r, inner_r) =
                                pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
                            let hit =
                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
                            if hit {
                                shared.select_row("trades", &key);
                            }
                            // 0.3.1(Wave D,BD-UI-49)双击联动:双击成交行 = 该合约进下单面板
                            // (Python 双击成交/持仓行带入下单,`mainwindow.py:276` 等价)。
                            let dbl = cell_r.double_clicked()
                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
                            if dbl {
                                let mut f = shared.order_form();
                                f.symbol = t.symbol.clone();
                                if let Some(d) = t.direction {
                                    f.direction = d;
                                }
                                shared.set_order_form(f);
                            }
                            // 0.3.1(Wave D)右键菜单(照 Python 表上右键:调整列宽 / 保存数据)。
                            // 只挂首列 = 一行一颗菜单,不在每格挂一颗。
                            // 0.3.3(收 Wave D 欠账):菜单挂在**真控件**上 —— 首格内容是
                            // `Label::new(..).sense(Sense::click())`(有 AccessKit 动作的真控件),
                            // 菜单由它自己的 `context_menu` 开。
                            // 0.4.1 五期(BD-UI-58):菜单里的「调整列宽」退役 —— 列边界可拖
                            // 与列线现在是全部表的固定形制(见 `pinned_table_impl_cols`),
                            // 一个只影响这一张表、还只影响「能不能拖」的开关成了空壳钮。
                        });
                    }};
                }
                // 首格是**真控件**(可点 + 可右键):右键菜单挂在这颗 Label 上。
                cell!(
                    |ui| {
                        let r = ui
                            .add(egui::Label::new(t.symbol.clone()).sense(egui::Sense::click()))
                            .on_hover_text("右键:保存数据");
                        r.context_menu(|ui| {
                            if ui.button("保存数据").clicked() {
                                let _ =
                                    save_table_csv("trade_grid", &TRADE_HEADERS, rows, trade_cells);
                                ui.close();
                            }
                        });
                        r
                    },
                    0
                );
                cell!(
                    |ui| ui.label(if t.name.is_empty() {
                        "-".to_string()
                    } else {
                        t.name.clone()
                    }),
                    1

                );
                cell!(|ui| ui.label(t.exchange.as_str().to_string()), 2);
                cell!(
                    |ui| ui.label(
                        egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
                            if t.direction == Some(Direction::SHORT) {
                                crate::tokens::semantic::FALL
                            } else {
                                crate::tokens::semantic::RISE
                            },
                        ),
                    ),
                    3

                );
                cell!(|ui| ui.label(t.offset.as_str().to_string()), 4);
                cell!(|ui| ui.label(crate::format::price(t.price)), 5);
                cell!(|ui| ui.label(crate::format::volume(t.volume)), 6);
                cell!(|ui| ui.label(crate::format::price(t.traded_amount)), 7);
                cell!(|ui| ui.label(crate::format::time_hms_millis(t.datetime)), 8);
                cell!(|ui| ui.label(t.tradeid.clone()), 9);
                cell!(|ui| ui.label(t.orderid.clone()), 10);
                cell!(
                    |ui| ui.label(ledger_gw_label(&t.gateway_name, known_gws)),
                    11

                );
            });
        }
        // 右键菜单已挂在第 1 格那颗真控件上(见体表 cell!(.., 0))。
    });
    if let Some(col) = clicked {
        shared.toggle_table_sort("trade_grid", col);
    }
}

/// 持仓格(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
/// Python mainwindow.py:276 等价,不开 K线)。
pub(crate) fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
    // 单 tab(Python `tab_widget3.addTab(position_widget, "持仓")`;BD-UI-41 补齐,
    // 0.4.0 删过,0.4.1 回归原生后恢复)。
    tab_label(ui, true, "持仓");
    let ts = shared.trade_snap();
    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
    let known_gws = account_gateways(shared);
    // 持仓表列头单源(空/非空两分支同源,0.2.7 审计口径)。
    const POS_HEADERS: [&str; 16] = [
        "代码",
        "名称",
        "交易所",
        "方向",
        "数量",
        "昨仓",
        "可用",
        "冻结",
        "在途",
        "市值",
        "成本",
        "均价",
        "最新价",
        "盈亏",
        "盈亏比",
        "接口",
    ];
    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59,同未成交口径)。
    // 0.3.3(收 Wave D 欠账):持仓表也吃「点表头排序」。
    let sort = shared.table_sort("pos_grid");
    let rows = sorted_rows(&ts.positions, sort, pos_sort_key);
    let clicked = pinned_table_sorted(ui, "pos_grid", &POS_HEADERS, sort, |body, row_h| {
        const RIGHT_COLS: [usize; 11] = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
        // move 格闭包要把这几样所有权收进去 —— 预先引用整形(同委托表口径)。
        let known_gws = &known_gws;
        for p in &rows {
            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
            // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
            // 不开 K线;方向随持仓方向,Python 同口径)。
            let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
            let row_sel = shared.row_selected("positions", &key);
            body.row(row_h, row_sel, |r| {
                macro_rules! cell {
                    ($make:expr, $col:expr) => {{
                        // key 是行内局部:格延迟画笔要活到 show,每格自备克隆(见委托表)。
                        let key = key.clone();
                        r.cell(move |ui, ctx| {
                            let (cell_r, inner_r) =
                                pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
                            let hit =
                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
                            if hit {
                                shared.select_row("positions", &key);
                            }
                            let dbl = cell_r.double_clicked()
                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
                            if dbl {
                                let mut f = shared.order_form();
                                f.symbol = p.symbol.clone();
                                f.exchange = p.exchange.as_str().to_string();
                                f.direction = p.direction;
                                // B1 修:行级网关带入(旧实现不带网关 → 平掉哪条腿由上一单残留决定)。
                                if !p.gateway_name.is_empty() {
                                    f.gateway = p.gateway_name.clone();
                                }
                                shared.set_order_form(f);
                                shared.log_line(format!(
                                    "[ops] 持仓带入下单面板 {}(接口 {})",
                                    p.symbol, p.gateway_name
                                ));
                            }
                        });
                    }};
                }
                cell!(|ui| ui.label(p.symbol.clone()), 0);
                cell!(
                    |ui| ui.label(if p.name.is_empty() {
                        "-".to_string()
                    } else {
                        p.name.clone()
                    }),
                    1

                );
                cell!(|ui| ui.label(p.exchange.as_str().to_string()), 2);
                cell!(
                    |ui| ui.label(egui::RichText::new(p.direction.as_str()).color(
                        if p.direction == ek_data::constant::Direction::SHORT {
                            crate::tokens::semantic::FALL
                        } else {
                            crate::tokens::semantic::RISE
                        },
                    )),
                    3

                );
                cell!(|ui| ui.label(crate::format::volume(p.volume)), 4);
                cell!(|ui| ui.label(crate::format::volume(p.yd_volume)), 5);
                cell!(|ui| ui.label(crate::format::volume(p.can_use_volume)), 6);
                cell!(|ui| ui.label(crate::format::volume(p.frozen)), 7);
                cell!(|ui| ui.label(crate::format::volume(p.on_road_volume)), 8);
                cell!(|ui| ui.label(crate::format::price(p.market_value)), 9);
                // 0.3.3 Wave F(BD-UI-51)两处收口:
                // ① 成本 / 均价原来**取值互换** —— ek-data 里 `avg_price` = 成本价、
                //    `price` = 持仓均价(position.rs:22-23 / 38-39),Python 也是
                //    成本 ← avg_price、均价 ← price(widget.py:1491-1492)。判据 F2 抓到这条。
                // ② 五处裸 `format!("{:.2}")` / `{:+.1}` / `{:+.4}` 改走 `format.rs`
                //    (Python BaseCell 是 `round(content, 3)`,同一张表里三种精度就是毛病 3)。
                cell!(|ui| ui.label(crate::format::price(p.avg_price)), 10);
                cell!(|ui| ui.label(crate::format::price(p.price)), 11);
                cell!(|ui| ui.label(crate::format::price(p.last_price)), 12);
                cell!(
                    |ui| ui.label(egui::RichText::new(crate::format::price(p.pnl)).color(
                        if p.pnl < 0.0 {
                            crate::tokens::semantic::FALL
                        } else {
                            crate::tokens::semantic::RISE
                        }
                    ),),
                    13

                );
                cell!(|ui| ui.label(crate::format::price(p.profit_rate)), 14);
                cell!(
                    |ui| ui.label(ledger_gw_label(&p.gateway_name, known_gws)),
                    15

                );
            });
        }
    });
    if let Some(col) = clicked {
        shared.toggle_table_sort("pos_grid", col);
    }
}

/// 持仓行的排序键(列序 == `POS_HEADERS`;与屏幕取值同源:
/// 成本 = `avg_price`、均价 = `price`,0.3.3 Wave F 修的那一对)。
fn pos_sort_key(p: &ek_data::position::PositionData, col: usize) -> String {
    match col {
        0 => p.symbol.clone(),
        1 => p.name.clone(),
        2 => p.exchange.as_str().to_string(),
        3 => p.direction.as_str().to_string(),
        4 => format!("{:020.4}", p.volume),
        5 => format!("{:020.4}", p.yd_volume),
        6 => format!("{:020.4}", p.can_use_volume),
        7 => format!("{:020.4}", p.frozen),
        8 => format!("{:020.4}", p.on_road_volume),
        9 => format!("{:020.4}", p.market_value),
        10 => format!("{:020.4}", p.avg_price),
        11 => format!("{:020.4}", p.price),
        12 => format!("{:020.4}", p.last_price),
        13 => format!("{:020.4}", p.pnl),
        14 => format!("{:020.4}", p.profit_rate),
        15 => p.gateway_name.clone(),
        _ => String::new(),
    }
}

/// 资金格(0.4.0:独立 dock 格 —— 原来它是「资金|日志」组内 tab 的一半)。
pub(crate) fn render_funds_pane(ui: &mut egui::Ui, shared: &UiShared) {
    let ts = shared.trade_snap();
    // 0.2.29(BD-UI-44):资金表跟着日志表换 TableBuilder —— 同一套固定表头 + 按内容
    // 列宽;余额/可用/冻结是数字列(右对齐成列)。
    //
    // 0.4.0 修正(用户实看:「资金…表头都没有」):空态**不得吞掉表头**。原来这里
    // `if accounts.is_empty() { muted_label; return; }` —— 没有网关时整张表连表头一起
    // 消失,而它就是「资金|日志」格默认前台的那一格,用户第一眼看到的就是一句空态。
    // 口径统一成「表头恒在」(委托/成交/持仓/日志同此);0.4.1 六期(BD-UI-59)起空态
    // 那一行提示文字也撤了 —— 表体就是空的,列宽按表头比例填满。
    pinned_table_impl(
        ui,
        "acct_grid",
        &["账号", "接口", "余额", "可用", "冻结"],
        None,
        |body, row_h| {
            const RIGHT_COLS: [usize; 3] = [2, 3, 4];
            for a in &ts.accounts {
                body.row(row_h, false, |r| {
                    macro_rules! cell {
                        ($make:expr, $col:expr) => {{
                            r.cell(|ui, ctx| {
                                let _ = pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
                            });
                        }};
                    }
                    cell!(|ui| ui.label(a.accountid.clone()), 0);
                    cell!(|ui| ui.label(a.gateway_name.clone()), 1);
                    // 0.3.3 Wave F:三处裸 `{:.2}` 改走 `format.rs`(同一口径,毛病 3)。
                    cell!(|ui| ui.label(crate::format::price(a.balance)), 2);
                    cell!(|ui| ui.label(crate::format::price(a.available())), 3);
                    cell!(|ui| ui.label(crate::format::price(a.frozen)), 4);
                });
            }
        },
    );
}

/// 日志表(BD-UI-41,对照 Python 版)。
///
/// Python 侧「日志」tab 里放的是 `LogMonitor` —— 一个带 **时间/信息/接口** 表头的
/// QTableWidget(`widget.py:1167-1180`,BaseMonitor 子类),跟着 Dock 一起伸展。
/// 我们原来是一坨 monospace 行 + `ScrollArea::max_height(180.0)` 硬顶:窗口最大化
/// 它还是 180px —— 用户报的「整体放到最大,日志那边表格还是很小」根因就是那 180。
/// 现在走 `pinned_table`(与委托/成交/持仓同一套容器:表头固定 + 体滚 + 横滚),
/// 硬顶撤除 → 格多大,表多大。
///
/// 两处偏离(记 DESIGN BD-UI-41):
/// ① Python 有第三列「接口」;我们的 `log_line` 调用点不带网关(正文自带 `[ops]` /
///   `[订阅]` 这类前缀),硬造一列空列不如不造,所以只有两列。
/// ② `stick_to_bottom` 撤除:Python 的 QTableWidget 也不自动跟到最后一行,
///   要看最新行自己滚到底(与那张表同口径)。
fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
    // 「清空日志」钮已撤除(0.4.1 用户裁定 2026-10-22:日志面不要这个钮)。
    // 状态层的 `UiShared::clear_logs()` 保留(它是状态口,测试与后续「日志面重置」
    // 都用得上),只是界面不再给入口。
    let (entries, spilled) = shared.logs_entries();
    if spilled > 0 {
        // 口径写明(G1 实连证据的收口):回扫日志取证不可靠 —— 巡回里「重加后 90 秒
        // 没抓到『交易服务器登录成功』行」不是没登录,是这一环被状态推送流挤满了。
        // 登录/掉线/查询被拒这些事实的事实源是账户面板的状态列与状态细节(网关状态口),
        // 不是日志窗。单独一行,不挤在钮旁:长文案在横向行里会被压(s39 那类实锤)。
        ui.label(format!(
            "已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里",
            crate::state::RING_CAP
        ));
    }
    // BD-UI-55(egui_table 迁入):日志正文逐帧生长 → 列宽走**接管版**(字体量串,
    // egui galley 缓存受益;不走库 per-frame auto-size,那条路 = 每帧双 pass,
    // 行动作用闭包会跑两遍 —— 适配层注释)。时间列定宽 90,信息列 = 最宽正文。
    let mono_font = egui::TextStyle::Monospace.resolve(ui.style());
    // 空态不接管列宽 —— 走「按表头比例分配」那条路(BD-UI-58)。
    let widths: Option<Vec<f32>> = if entries.is_empty() {
        None
    } else {
        Some(vec![
            {
                let g = ui.painter().layout_no_wrap(
                    "18:00:00.000".to_string(),
                    mono_font.clone(),
                    egui::Color32::TRANSPARENT,
                );
                g.size().x + 12.0
            },
            {
                let mut w: f32 = 120.0;
                for e in &entries {
                    let g = ui.painter().layout_no_wrap(
                        e.text.clone(),
                        mono_font.clone(),
                        egui::Color32::TRANSPARENT,
                    );
                    w = w.max(g.size().x + 12.0);
                }
                w
            },
        ])
    };
    pinned_table_impl_cols(
        ui,
        "log_grid",
        &["时间", "信息"],
        None,
        widths,
        |body, row_h| {
            for e in &entries {
                body.row(row_h, false, |r| {
                    r.cell(|ui, _ctx| {
                        ui.monospace(e.at.clone());
                    });
                    r.cell(|ui, _ctx| {
                        ui.monospace(e.text.clone());
                    });
                });
            }
        },
    );
}

// ================= 独立窗面(BD-UI-32/33)=================

/// 独立窗几何(BD-UI-34:助手参数收敛,8→6 闸 clippy too_many_arguments)。
struct WinGeom {
    inner: [f32; 2],
    min_inner: [f32; 2],
    maximized: bool,
}

/// 独立 OS 窗统一开法(BD-UI-33,四窗一型:运维/CTP/查询合约/K线)。
///
/// - **守卫(0.2.3 教训内建):** 调用方按状态位辞行——位关不调本函数 =
///   viewport 本拍未 used = egui/eframe GC 销毁 OS 窗(销毁判据源码
///   context.rs「never used this pass」)。
/// - Embedded 回退(后端不支持多窗:kittest/WEB)= 主窗内 egui::Window 同本体,
///   回退面关闭 = embedded 闭包返回 true。
/// - Immediate 面 OS X = close_requested → 返回 true(调用方翻负状态位)。
/// - `geom.maximized` = 打开即最大化(BD-UI-34,K线窗用户裁定;OS 窗属性,
///   Embedded 回退面无此概念 = 忽略)。
/// - `body` 返回 true = 内容侧请求关闭(BD-UI-34:K线窗 ESC 键快捷关闭;
///   键事件按子窗焦点路由,主窗按键不会误关)。
///
/// 返回 = "本拍该窗被关"(调用方负责状态位/列表出表)。
fn show_independent_window(
    ctx: &egui::Context,
    id_salt: impl std::hash::Hash,
    title: &str,
    geom: &WinGeom,
    embedded: impl Fn(&egui::Context) -> bool,
    body: impl Fn(&mut egui::Ui) -> bool,
) -> bool {
    let closed = std::cell::Cell::new(false);
    let mut builder = egui::ViewportBuilder::default()
        .with_title(title)
        .with_inner_size(geom.inner)
        .with_min_inner_size(geom.min_inner)
        .with_minimize_button(true)
        .with_maximize_button(true)
        .with_close_button(true);
    if geom.maximized {
        builder = builder.with_maximized(true);
    }
    ctx.show_viewport_immediate(
        egui::ViewportId::from_hash_of(id_salt),
        builder,
        |ctx2, class| {
            if class == egui::ViewportClass::Embedded {
                closed.set(embedded(ctx2));
                return;
            }
            if ctx2.input(|i| i.viewport().close_requested()) {
                closed.set(true);
                return;
            }
            egui::CentralPanel::default().show(ctx2, |ui| {
                if body(ui) {
                    closed.set(true);
                }
            });
        },
    );
    closed.get()
}

fn render_windows(ctx: &egui::Context, shared: &UiShared) {
    // 运维窗(菜单「运维」;BD-UI-33 转独立 OS 窗,用户裁定「四窗皆可脱离主窗」)。
    if shared.ops_window_open() {
        let closed = show_independent_window(
            ctx,
            "ops_win",
            "运维窗",
            &WinGeom {
                inner: [680.0, 420.0],
                min_inner: [420.0, 260.0],
                maximized: false,
            },
            |ctx2| {
                let mut open = shared.ops_window_open();
                egui::Window::new("运维窗")
                    .id(egui::Id::new("ops_win"))
                    .default_pos([80.0, 320.0])
                    .default_size([640.0, 380.0])
                    .open(&mut open)
                    .show(ctx2, |ui| {
                        render_ops_window(ui, shared);
                    });
                shared.set_ops_window_open(open);
                !open
            },
            |ui| {
                render_ops_window(ui, shared);
                false
            },
        );
        if closed {
            shared.set_ops_window_open(false);
        }
    }

    // 账户面板(菜单「账户」;多账号面:账户清单 + 行情源选择 + 添加/编辑表单;
    // 旧「CTP 网关配置窗」退役收纳于此窗,BD-UI-33 独立 OS 窗形态沿用)。
    if shared.accounts_window_open() {
        let closed = show_independent_window(
            ctx,
            "account_win",
            "账户面板",
            &WinGeom {
                inner: [760.0, 520.0],
                min_inner: [520.0, 320.0],
                maximized: false,
            },
            |ctx2| {
                let mut open = shared.accounts_window_open();
                egui::Window::new("账户面板")
                    .id(egui::Id::new("account_win"))
                    .default_pos([640.0, 320.0])
                    .default_size([720.0, 470.0])
                    .open(&mut open)
                    .show(ctx2, |ui| {
                        render_accounts_window(ui, shared);
                    });
                shared.set_accounts_window_open(open);
                !open
            },
            |ui| {
                render_accounts_window(ui, shared);
                false
            },
        );
        if closed {
            shared.set_accounts_window_open(false);
        }
    }

    // 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
    // 『添加账户』与『编辑 <账户>』开同一个弹窗的两种模式;**非 modal**(不阻塞主窗与行情观察)。
    // 关闭(窗 × 或『取消』)= 放弃未保存(规划 Q24),绝不静默写入清册留档。
    if shared.account_edit_open() {
        let closed = show_independent_window(
            ctx,
            "account_edit_win",
            "账户配置",
            &WinGeom {
                inner: [680.0, 340.0],
                min_inner: [480.0, 240.0],
                maximized: false,
            },
            |ctx2| {
                let mut open = shared.account_edit_open();
                let title = match shared.editing_account() {
                    Some(gw) => format!("账户配置 — 编辑 {gw}"),
                    None => "账户配置 — 添加".to_string(),
                };
                egui::Window::new(title)
                    .id(egui::Id::new("account_edit_win"))
                    .default_pos([700.0, 380.0])
                    .default_size([660.0, 320.0])
                    .open(&mut open)
                    .show(ctx2, |ui| {
                        render_account_edit_window(ui, shared);
                    });
                if !open {
                    shared.end_account_edit();
                }
                !open
            },
            |ui| {
                render_account_edit_window(ui, shared);
                false
            },
        );
        if closed && shared.account_edit_open() {
            shared.end_account_edit();
        }
    }

    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)。
    if shared.contracts_window_open() {
        let closed = show_independent_window(
            ctx,
            "contracts_win",
            "查询合约窗",
            &WinGeom {
                inner: [620.0, 460.0],
                min_inner: [420.0, 260.0],
                maximized: false,
            },
            |ctx2| {
                let mut open = shared.contracts_window_open();
                egui::Window::new("查询合约窗")
                    .id(egui::Id::new("contracts_win"))
                    .default_pos([420.0, 300.0])
                    .default_size([560.0, 420.0])
                    .open(&mut open)
                    .show(ctx2, |ui| {
                        render_contracts_window(ui, shared);
                    });
                shared.set_contracts_window_open(open);
                !open
            },
            |ui| {
                render_contracts_window(ui, shared);
                false
            },
        );
        if closed {
            shared.set_contracts_window_open(false);
        }
    }

    // K线窗(双击弹出,按合约多开;出 kline_symbols 列表 = 不再 show = 销毁)。
    // BD-UI-34:打开即最大化(maximized)+ ESC 键快捷关闭(body 请求关闭;
    // 键按子窗焦点路由,主窗按键不误关)。
    let mut closed: Vec<String> = Vec::new();
    for sym in shared.kline_symbols() {
        let is_closed = show_independent_window(
            ctx,
            format!("kline_win:{sym}"),
            &format!("K线 — {sym}"),
            &WinGeom {
                inner: [560.0, 360.0],
                min_inner: [360.0, 220.0],
                maximized: true,
            },
            |ctx2| {
                let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
                let resp = egui::Window::new(format!("K线 — {sym}"))
                    .id(egui::Id::new(("kline_win", sym.clone())))
                    .default_pos([300.0, 340.0])
                    .default_size([540.0, 330.0])
                    .show(ctx2, |ui| {
                        let cell = shared
                            .market()
                            .cells()
                            .into_iter()
                            .find(|c| c.symbol == sym);
                        render_kline_cell(ui, cell.as_ref());
                    });
                esc || resp.is_none()
            },
            |ui| {
                if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
                    return true;
                }
                let cell = shared
                    .market()
                    .cells()
                    .into_iter()
                    .find(|c| c.symbol == sym);
                render_kline_cell(ui, cell.as_ref());
                false
            },
        );
        if is_closed {
            closed.push(sym);
        }
    }
    for s in closed {
        shared.close_kline(&s);
    }
}

/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
/// + 依赖图。回灌界面面已退役(总线口保留;流控查询钮 0.2.10 随 sim 退场撤除)。
fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
    crate::ui_ext::section_title(ui, "插件");
    // 0.5 M3:装配显形改吃事件(启动 / `ePlugUp.oms`),这里只留一个手动口子。
    // 为什么放在「插件」节而不是「oms 管线」节:问不到时那一节整节不画(s87b 口径),
    // 钮挂在那儿就等于「有读数才给重问」—— 恰恰在没读数的时候最需要重问。
    ui.horizontal(|ui| {
        if ui.small_button(BTN_PIPELINE_REFRESH).clicked() {
            shared.request_pipeline_refresh();
        }
        ui.label(
            egui::RichText::new("管线读数只在 oms 上线 / 启动 / 这里手动触发时问一次")
                .color(crate::tokens::semantic::INFO),
        );
    });
    let snap = shared.snapshot();
    // 账户清册里的实例名(一次取好,别在每行每帧各 clone 一遍整表)。
    let account_gateways: std::collections::HashSet<String> = shared
        .accounts()
        .iter()
        .map(|a| a.gateway.clone())
        .collect();
    let mut plug_heads: Vec<(egui::Rect, String)> = Vec::new();
    let mut plug_rows: Vec<f32> = Vec::new();
    // 列宽下限(BD-UI-60:短表表宽 = 所在区域 100%,口径与实现见 `ui_ext::grid_col_width`)。
    let plug_col_w = crate::ui_ext::grid_col_width(ui, "plug_grid", 6);
    // 行高下限与表格层同一口径(BD-UI-61:`table_heights` 同一对数,短表行高 = 主表行高)。
    let plug_row_h = table_heights(ui).0;
    egui::Grid::new("plug_grid")
        .num_columns(6)
        .min_col_width(plug_col_w)
        .min_row_height(plug_row_h)
        .show(ui, |ui| {
            for h in ["名称", "状态", "版本", "依赖", "统计", "操作"] {
                let r = ui.label(egui::RichText::new(h).strong().small());
                plug_heads.push((r.rect, h.to_string()));
            }
            ui.end_row();
            // 空态不再画提示行(0.4.1 六期 BD-UI-59):快照空 = 这张表只有表头那一行。
            for r in &snap.rows {
                if is_quarantined(&r.state) {
                    ui.label(
                        egui::RichText::new(r.name.clone()).color(crate::tokens::semantic::ERROR),
                    );
                    ui.label(
                        egui::RichText::new(state_text(r))
                            .color(crate::tokens::semantic::ERROR)
                            .strong(),
                    );
                } else {
                    ui.label(r.name.clone());
                    ui.label(state_text(r));
                }
                ui.label(r.version.clone());
                ui.label(format!("deps:[{}]", r.deps.join(",")));
                // 统计列(0.4.5,P7d):溢出与 panic 计数此前与其余列同色,读的人要逐格找。
                // 现在按最坏的那一项上色:0/0 走 MUTED(它是「没事」的意思,不是数据),
                // 溢出 > 0 走 WARN,溢出跨过邮箱容量或有任何 panic 走 ERROR。
                // 只上色不改布局(这一格仍是一个 label,列对齐与表头带几何都不动)。
                ui.label(
                    egui::RichText::new(format!("spilled:{} panics:{}", r.spilled, r.panics))
                        .color(stat_color(
                            r.spilled,
                            r.panics,
                            crate::state::mailbox_cap_of(&r.name) as u64,
                        )),
                );
                ui.horizontal(|ui| {
                    // Q17 收口(0.2.20):账户清册里的实例不在运维窗起停。
                    // 账户面板的『移除』带全套守卫(活跃委托闸 + 二次确认 + 持仓提示),
                    // 运维窗这条是同一动作的无守卫副本 —— 同一个实例两条移除路、守卫只长在
                    // 一条上,等于守卫可以绕过(规划 Q17 的边界就在这)。
                    // 清册外的 `ctp_*` 实例(手工起的 / 旧流程残留)仍留这条出口,否则没有退路。
                    if account_gateways.contains(&r.name) {
                        ui.label(
                            egui::RichText::new("起停归『账户』面板(带撤单守卫)")
                                .color(crate::tokens::semantic::INFO),
                        );
                    } else if r.name == UI_NAME || r.name == SVCTL_NAME {
                        // 管制面与观测面自身不给移除钮(0.4.3 BD-UI-64,审核 P1)。
                        // 摘 svctl = 单向门:工厂表(default_kinds / ui_kinds)都不登记 "svctl",
                        // 摘掉后连『重注入 svctl』也只会回 unknown factory,启停面没有恢复入口。
                        // 摘 ui = 自己等自己:这条指令正是 ui 自己的 worker 在执行,只能走到
                        // ek-core close_one 的 2 秒超时 detach 分支,窗口还在但不再刷新。
                        // 插件侧同一道闸:supervisor 拒摘 svctl(ek-station 0.2.11)、
                        // UiPlugin 直拒摘 ui(本 crate plugin.rs)。
                        ui.label(
                            egui::RichText::new("管制面/观测面自身:不在运维窗摘除")
                                .color(crate::tokens::semantic::INFO),
                        );
                        if ui.small_button(format!("重注入 {}", r.name)).clicked() {
                            shared.push_cmd(UiCmd::Start {
                                factory: r.name.clone(),
                                name: String::new(),
                            });
                        }
                    } else {
                        if ui.small_button(format!("重注入 {}", r.name)).clicked() {
                            // 运维窗按「注册名 = 工厂名」的旧插件重注入(dm/oms/ui);
                            // 命名网关实例(`ctp_<broker>_<账号>`)不是工厂名,会被
                            // supervisor 回拒 unknown factory —— 那类实例的起停归「账户」面板。
                            shared.push_cmd(UiCmd::Start {
                                factory: r.name.clone(),
                                name: String::new(),
                            });
                        }
                        let armed = shared.pending_confirm();
                        let armed_here = ui
                            .small_button(remove_button_label(&r.name, armed.as_deref()))
                            .clicked()
                            && shared.confirm_step(&r.name) == ConfirmStep::Fired;
                        if armed_here {
                            shared.push_cmd(UiCmd::Remove {
                                name: r.name.clone(),
                            });
                        }
                    }
                });
                plug_rows.push(ui.min_rect().max.y);
                ui.end_row();
            }
        });
    let plug_band = header_band(ui, &plug_heads, ui.min_rect().max.x);
    grid_chrome(ui, plug_band, &plug_heads, &plug_rows);

    if let Some(name) = shared.pending_confirm() {
        ui.horizontal(|ui| {
            ui.label(
                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
                    .color(crate::tokens::semantic::WARN),
            );
            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
                shared.confirm_cancel();
            }
        });
    }

    // oms 管线(0.4.4,P5-a 裁定接的那条服务):装配显形。
    // 插件表给「谁在场」,这一节给「oms 里面装了什么」——五段管线 / 限速与撤单守卫 /
    // 拆单器 / 每道风控闸一行(工厂注入 ek_risk::standard_gates 缺省限额那四道)。
    // 问不到(oms 不在场 → TargetGone 快错,或超时)整节不画:这里没有表、没有可空的
    // 东西,与六期 BD-UI-59「空态 = 表头 + 0 行」不是同一种空。
    let pipeline = shared.pipeline_lines();
    if !pipeline.is_empty() {
        crate::ui_ext::space_group(ui);
        crate::ui_ext::section_title(ui, "oms 管线");
        for line in &pipeline {
            ui.label(egui::RichText::new(line).monospace());
        }
    }

    // 配置托底(0.6.0,BD-CFG 环节8):段落目录(零值面)+ 清段/重存段。
    // save 联线的另一半长在网关自己身上(`{gw}.configure` 受理成功 → 三行序,
    // ek-station DESIGN BD-CFG-14 的 0.10.1 扩)。这里给的是运维面:
    // 看段落目录(哪个 ns 有哪些键、哪些是引用)、修格式 rev、误配后抹段。
    crate::ui_ext::space_group(ui);
    crate::ui_ext::section_title(ui, "配置托底");
    ui.horizontal(|ui| {
        if ui.small_button(BTN_CFG_REFRESH).clicked() {
            shared.arm_cfg_delete(None);
            shared.request_cfg_refresh();
        }
        ui.label(
            egui::RichText::new(format!(
                "段落目录零值面(只要键名/形态;小键名 = 明面,(引用) = {} 形)",
                ek_cfg::REF_PREFIX
            ))
            .color(crate::tokens::semantic::INFO),
        );
    });
    match shared.cfg_briefs() {
        None => {
            ui.label(
                egui::RichText::new("(未问过段落目录;点『刷新段落目录』再画)")
                    .color(crate::tokens::semantic::MUTED),
            );
        }
        Some(briefs) if briefs.is_empty() => {
            ui.label(
                egui::RichText::new("(无段落;配置受理成功后会出现)")
                    .color(crate::tokens::semantic::MUTED),
            );
        }
        Some(briefs) => {
            let b_col_w = crate::ui_ext::grid_col_width(ui, "cfgb_grid", 4);
            let b_row_h = table_heights(ui).0;
            egui::Grid::new("cfgb_grid")
                .num_columns(4)
                .min_col_width(b_col_w)
                .min_row_height(b_row_h)
                .show(ui, |ui| {
                    for h in ["ns", "rev", "键", "操作"] {
                        ui.label(egui::RichText::new(h).strong().small());
                    }
                    ui.end_row();
                    for b in &briefs {
                        ui.label(b.ns.clone());
                        ui.label(b.rev.to_string());
                        ui.label(format!(
                            "{} 条:{}",
                            b.keys.len(),
                            b.keys
                                .iter()
                                .map(|k| if k.secret {
                                    format!("{}(引用)", k.key)
                                } else {
                                    k.key.clone()
                                })
                                .collect::<Vec<_>>()
                                .join("、"),
                        ));
                        ui.horizontal(|ui| {
                            let armed = shared.cfg_delete_armed();
                            let label = if armed.as_deref() == Some(b.ns.as_str()) {
                                format!("再次点击 清段 {}", b.ns)
                            } else {
                                format!("清段 {}", b.ns)
                            };
                            if ui.small_button(label).clicked() {
                                if armed.as_deref() == Some(b.ns.as_str()) {
                                    shared.arm_cfg_delete(None);
                                    shared.push_cmd(UiCmd::CfgDelete { ns: b.ns.clone() });
                                } else {
                                    shared.arm_cfg_delete(Some(b.ns.clone()));
                                }
                            }
                            if ui
                                .small_button(format!("重存段 {}", b.ns))
                                .clicked()
                            {
                                shared.push_cmd(UiCmd::CfgResave { ns: b.ns.clone() });
                            }
                        });
                        ui.end_row();
                    }
                });
        }
    }

    crate::ui_ext::space_group(ui);
    crate::ui_ext::section_title(ui, "服务");
    let flat: Vec<String> = snap
        .services
        .iter()
        .flat_map(|(n, ids)| {
            if ids.is_empty() {
                vec![format!("{n} 服务"), "-".to_string()]
            } else {
                ids.iter()
                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
                    .collect()
            }
        })
        .collect();
    // 表头(BD-UI-41,用户指出「配置那边表格也要有表头」):这张表原来六列纯数据没有头行,
    // 列语义 = 三对「服务名 / 实例 id」,头就照这个写。
    // 0.4.0 修正(用户实看:「运维…的表格也都要处理」):空态**不得吞掉表头**。原来空态只剩
    // 一句「(无服务目录)」,表头带与列宽全没了 —— 与资金格同一个病;口径 = 表头恒画。
    // (当时补的那句「空态是表体内的一行」到六期 BD-UI-59 撤除:空态 = 表头 + 0 行。)
    let mut svc_heads: Vec<(egui::Rect, String)> = Vec::new();
    let mut svc_rows: Vec<f32> = Vec::new();
    // 列宽下限同 plug_grid(BD-UI-60)。
    let svc_col_w = crate::ui_ext::grid_col_width(ui, "svc_grid", 6);
    let svc_row_h = table_heights(ui).0;
    egui::Grid::new("svc_grid")
        .num_columns(6)
        .min_col_width(svc_col_w)
        .min_row_height(svc_row_h)
        .show(ui, |ui| {
            for _ in 0..3 {
                for h in ["服务", "实例"] {
                    let r = ui.label(egui::RichText::new(h).strong().small());
                    svc_heads.push((r.rect, h.to_string()));
                }
            }
            ui.end_row();
            // 空态不再画提示行(0.4.1 六期 BD-UI-59):目录空 = 这张表只有表头那一行
            // (`rows = 0` → 循环不跑),表头带照画。
            let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
            for r in 0..rows {
                for c in 0..3 {
                    let i = c * rows * 2 + r * 2;
                    if i < flat.len() {
                        ui.label(flat[i].clone());
                        ui.label(flat[i + 1].clone());
                    } else {
                        ui.label("");
                        ui.label("");
                    }
                }
                svc_rows.push(ui.min_rect().max.y);
                ui.end_row();
            }
        });
    let svc_band = header_band(ui, &svc_heads, ui.min_rect().max.x);
    grid_chrome(ui, svc_band, &svc_heads, &svc_rows);

    ui.label("依赖");
    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
        ui.label("(全部无依赖)");
    }
    for r in &snap.rows {
        if !r.deps.is_empty() {
            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
        }
    }
}

/// 账户面板(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
/// 列表上方 = 行情源一览 + 账户计数(软上限)+ 『添加账户』入口;
/// 列表 = **跨账户一览**(状态 / 行情源 / 收录勾选与网关实际收录 / 行操作);
/// 配置表单**移入独立弹窗**(`render_account_edit_window`)—— 本窗不再内嵌表单,
/// 旧「窗内一张共享表单」退役(消 U4 凭据串味 / U6 取消编辑不清空的界面根)。
fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
    let accounts = shared.accounts();
    let locked = shared.accounts_locked();
    let md = shared.md_source();
    let snap = shared.trade_snap();

    // 行内显示名(别名优先,空回退账号;重名回退派生名)。
    // 同一行里『编辑 X』与『移除 X』必须用同一个 X(规划 U10 口径齐),
    // 而寻址恒用派生名 —— 别名只是显示,不参与寻址(规划 Q3)。
    let row_label: std::collections::BTreeMap<String, String> = accounts
        .iter()
        .map(|a| {
            let d = a.display_name();
            let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
            (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
        })
        .collect();

    // 列表上方:行情源一览(订阅一律走它,与账户增删无关)+ 计数 + 添加入口。
    ui.horizontal(|ui| {
        ui.label(
            egui::RichText::new(format!(
                "当前行情源 = {}",
                if md.is_empty() {
                    "(未指定)".to_string()
                } else {
                    md.clone()
                }
            ))
            .strong(),
        );
        ui.label(format!(
            "账户 {}/{}",
            accounts.len(),
            crate::state::MAX_ACCOUNTS
        ));
        if shared.can_add_account() {
            if ui.button(BTN_ACCOUNT_ADD).clicked() {
                shared.begin_account_edit(None);
            }
        } else {
            ui.label(
                egui::RichText::new(format!(
                    "账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
                    crate::state::MAX_ACCOUNTS
                ))
                .color(crate::tokens::semantic::WARN),
            );
        }
    });

    const ACCOUNT_HEADERS: [&str; 8] = [
        "网关(派生名)",
        "别名",
        "账号",
        "状态",
        "阶段",
        "行情源",
        "收录(勾→实)",
        "操作",
    ];
    // 0.2.17 布局修复:账户清单不用 pinned_table。pinned_table = ScrollArea
    // (auto_shrink=false,吃掉整块可用高)+ 覆盖式表头带;账户窗是**自动尺寸浮动窗**,
    // 两者凑不出正确高度 → 表行与其后的口径句**互相压字**(快照钉 s39 实锤)。
    // 账户行最多个位数,普通 Grid 就够,不需要固定表头滚动面 —— 但表头带照补
    // (BD-UI-41:走 `header_band`,它正是为「自动尺寸浮动窗里的短表」准备的)。
    let mut acct_heads: Vec<(egui::Rect, String)> = Vec::new();
    let mut acct_rows: Vec<f32> = Vec::new();
    // 列宽下限同 plug_grid(BD-UI-60)。
    let acct_col_w = crate::ui_ext::grid_col_width(ui, "account_grid", 8);
    let acct_row_h = table_heights(ui).0;
    egui::Grid::new("account_grid")
        .num_columns(8)
        .min_col_width(acct_col_w)
        .min_row_height(acct_row_h)
        .show(ui, |ui| {
            for h in &ACCOUNT_HEADERS {
                let r = ui.label(egui::RichText::new(*h).strong().small());
                acct_heads.push((r.rect, h.to_string()));
            }
            ui.end_row();
        // 空态不再画提示行(0.4.1 六期 BD-UI-59):清册空 = 这张表只有表头那一行。
        for a in &accounts {
            let is_md = a.gateway == md;
            let name = row_label
                .get(&a.gateway)
                .cloned()
                .unwrap_or_else(|| a.gateway.clone());
            ui.label(a.gateway.clone());
            // 别名(空 = 显示 "—";寻址恒用派生名,别名不参与,规划 Q3)。
            ui.label(if a.alias.trim().is_empty() {
                "—".to_string()
            } else {
                a.alias.clone()
            });
            ui.label(a.account.clone());
            // 状态列(0.2.19 起两段):装配态(svctl 在场位)+ **登录态**(`{gw}.status` 读数)。
            // 登录成败不再靠猜:ek-ctp 0.9.0 的 `{gw}.status` 是事实源(规划 P3 / 裁定 3),
            // 没有读数时仍写「已装配·登录态未知」,不得写成「已连接」(规划 U5 口径不变)。
            // 文案一律 `Extend`(不换行):账户面板是 Grid,状态列被挤窄时逐字竖排
            // 会把一行撑成十几行高(s39 快照实锤过)。
            match a.stage {
                crate::state::AccountStage::AssembleFailed => {
                    ui.add(
                        egui::Label::new(
                            egui::RichText::new(format!(
                                "装配失败:{}",
                                a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
                            ))
                            .color(crate::tokens::semantic::ERROR),
                        )
                        .wrap_mode(egui::TextWrapMode::Extend),
                    );
                }
                _ => {
                    let color = match a.login {
                        crate::state::LoginState::Online => crate::tokens::semantic::OK,
                        crate::state::LoginState::LoginFailed
                        | crate::state::LoginState::FrontDown => {
                            crate::tokens::semantic::ERROR
                        }
                        crate::state::LoginState::QueryStarved => {
                            crate::tokens::semantic::WARN
                        }
                        // Unknown / Stopped:灰 —— 没有证据与主动停会话都不该着色成"出事"
                        _ => crate::tokens::semantic::MUTED,
                    };
                    // 0.4.1 十期 BD-UI-63(用户裁定):登录被拒时,**柜台原因直接跟着红字打在账户行上**
                    // (实连实锤:220380 在 7×24 套被柜台拒「不合法的登录(3)」,而账户行只写
                    // 「已装配·登录失败」—— 用户只看到"登不上",看不到"为什么登不上")。
                    // 只跟短标签(整句在窗下方「状态细节」那一行给全):Grid 状态列被长句挤宽
                    // 会把别的列顶窄(s39 口径)。
                    let text = match (a.login.is_alarm(), a.login_brief.as_ref()) {
                        (true, Some(brief)) => format!("{}·{}", a.status_label(), brief),
                        _ => a.status_label(),
                    };
                    ui.add(
                        egui::Label::new(egui::RichText::new(text).color(color))
                            .wrap_mode(egui::TextWrapMode::Extend),
                    );
                }
            }
            // 阶段列(0.5.0,用户定「链进行的状态改为事件日志推送」):该网关最近一条 `[链] `
            // 状态行的正文(ek-ctp 0.9.8 推的链状态)。`None` = 这一条会话还没推过,或实例已不在场
            // (`sync_accounts_connected` 一并作废)→ 「—」。
            // 它只是显示:不参与状态列那套在线/断供判据。与状态列一样 `Extend`——Grid 里
            // 被挤窄会逐字竖排(s39 实锤)。
            ui.add(
                egui::Label::new(
                    egui::RichText::new(a.chain_note.clone().unwrap_or_else(|| "—".to_string()))
                        .color(crate::tokens::semantic::MUTED)
                        .small(),
                )
                .wrap_mode(egui::TextWrapMode::Extend),
            );
            // 行情元账号:配置阶段任选;任何账户已装配即锁定(规划 D3)。
            ui.horizontal(|ui| {
                if is_md {
                    ui.label(egui::RichText::new("● 行情源").strong());
                }
                let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
                    ui.small_button(if is_md {
                        "已是行情源"
                    } else {
                        "设为行情源"
                    })
                });
                if resp.inner.clicked() {
                    match shared.set_md_source(&a.gateway) {
                        Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
                        Err(e) => shared.log_line(format!("[账户] {e}")),
                    }
                }
            });
            // 收录:勾选(意图)→ 网关实际回带(事实),并排显示(规划 U9)。
            // 旧口径:`ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
            ui.vertical(|ui| {
                ui.add(
                    egui::Label::new(egui::RichText::new(format!(
                        "勾:{}",
                        classes_label(&a.form.product_classes())
                    )))
                    .wrap_mode(egui::TextWrapMode::Extend),
                );
                ui.add(
                    egui::Label::new(egui::RichText::new(format!(
                        "实:{}",
                        if a.effective_classes.is_empty() {
                            "—".to_string()
                        } else {
                            classes_label(&a.effective_classes)
                        }
                    )))
                    .wrap_mode(egui::TextWrapMode::Extend),
                );
            });
            // 操作:连接/重试(四态分离的『连接』入口)/ 断开(0.2.19 停会话)/ 编辑(开配置弹窗)/ 移除(守卫 + 先撤入口)。
            ui.horizontal(|ui| {
                let failed = a.stage == crate::state::AccountStage::AssembleFailed;
                let idle = a.stage == crate::state::AccountStage::NotAssembled;
                // 会话被主动停掉(『断开』)但实例还在:恢复入口仍是这一颗『连接』
                // (走 `UiCmd::Connect` → svctl 回「已在场」→ 插件侧改走重投凭据,规划裁定 1)。
                let stopped = a.login == crate::state::LoginState::Stopped;
                if failed || idle || stopped {
                    // 规划 U3 定盘:装配失败**不回滚清册行**,行留着给『重试』
                    // (旧口径"必须先移除才能重试"作废)。
                    let label = if failed {
                        format!("重试 {name}")
                    } else {
                        format!("连接 {name}")
                    };
                    if ui.small_button(label).clicked() {
                        push_connect(shared, &a.gateway, &a.form);
                    }
                }
                // 该账户的活跃委托数(『断开』与『移除』共用同一道守卫,见下)。
                let open = snap
                    .orders
                    .iter()
                    .filter(|o| o.gateway_name == a.gateway && o.is_active())
                    .count();
                // 『断开』(0.2.19,规划裁定 1「断开 = 停会话,移除 = 停实例」):
                // 只对**已装配、会话还活着、且没有活跃委托**的行开。
                // - 未装配没有会话可停(规划 Q21:口不存在时不留死钮;现在口已实现,钮才允许出现);
                // - 有活跃委托时不给断开 —— 会话一停就撤不了单、也不再回报,与『移除』
                //   要挡的是同一个观测黑洞(规划 §5.B),出口同样是旁边那句『先撤 N 笔』。
                // `&&` 短路即"条件不成立就不画这颗钮",不是画出来再禁用。
                if a.stage == crate::state::AccountStage::Assembled
                    && a.login != crate::state::LoginState::Stopped
                    && open == 0
                    && ui.small_button(format!("断开 {name}")).clicked()
                {
                    shared.push_cmd(UiCmd::Disconnect {
                        gateway: a.gateway.clone(),
                    });
                    shared.log_line(format!(
                        "[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
                        gateway = a.gateway
                    ));
                }
                if ui.small_button(format!("编辑 {name}")).clicked() {
                    // 编辑 = 开配置弹窗(草稿 = 该账户自己的留档)。
                    // 旧的 `select_row("account_grid", …)` 行选记槽已无读者(内嵌表单时代遗留)→ 撤除。
                    shared.begin_account_edit(Some(&a.gateway));
                }
                let has_pos = snap
                    .positions
                    .iter()
                    .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
                if open > 0 {
                    // 守卫硬挡:柜台还在跑单而没人能撤 = 观测黑洞(规划 §5.B)。
                    ui.label(
                        egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
                            .color(crate::tokens::semantic::WARN),
                    );
                    // 规划 U10:守卫不能只把用户堵在门口,要给出口 ——
                    // 『先撤 N 笔』按该账户的活跃委托逐笔入列(与「全撤」同一条 oms 通道,
                    // 走每笔单自己的网关,撞撤单计数守卫时显形 limit:cancel-guard)。
                    if ui.small_button(format!("先撤 {open} 笔")).clicked() {
                        for o in snap
                            .orders
                            .iter()
                            .filter(|o| o.gateway_name == a.gateway && o.is_active())
                        {
                            shared.push_cmd(UiCmd::CancelOrder {
                                orderid: o.orderid.clone(),
                                gateway: a.gateway.clone(),
                            });
                        }
                        shared.log_line(format!(
                            "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
                            a.gateway, open
                        ));
                    }
                } else {
                    // 二次确认的记槽按**派生名**(寻址主键),按钮文案用显示名 ——
                    // 两者必须在这里对上,否则首点后按钮永远翻不到『确认移除』(Commit B 自查抓到的坑)。
                    let armed = shared.pending_confirm();
                    let armed_here = armed.as_deref() == Some(a.gateway.as_str());
                    let fired = ui
                        .small_button(remove_button_label(
                            &name,
                            if armed_here { Some(name.as_str()) } else { None },
                        ))
                        .clicked()
                        && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
                    if fired {
                        shared.push_cmd(UiCmd::Remove {
                            name: a.gateway.clone(),
                        });
                        shared.remove_account(&a.gateway);
                        // 弹窗是非 modal:若这行正在被编辑,摘除必须连带关弹窗放弃,
                        // 否则弹窗会静默退化成「添加模式」,点『保存』会拿旧凭据建新账户。
                        shared.end_account_edit_if_editing(&a.gateway);
                        if has_pos {
                            shared.log_line(format!(
                                "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
                                a.gateway
                            ));
                        }
                    }
                }
            });
            acct_rows.push(ui.min_rect().max.y);
            ui.end_row();
        }
    });
    let acct_band = header_band(ui, &acct_heads, ui.min_rect().max.x);
    grid_chrome(ui, acct_band, &acct_heads, &acct_rows);

    if let Some(gw) = shared.pending_confirm() {
        // 提示句与行内钮同一显示名(行里写『移除 220380』,提示就不能写『移除 ctp_9999_220380』)。
        let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
        ui.horizontal(|ui| {
            ui.label(
                egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
                    .color(crate::tokens::semantic::WARN),
            );
            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
                shared.confirm_cancel();
            }
        });
    }

    ui.label(if locked {
        "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
    } else {
        "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
    });
    // 状态口径统一说明(面板底部一句,不逐行重复 —— 逐行会把状态列撑成竖排):
    ui.label(
        egui::RichText::new(format!(
            "状态列 = 实例装配 + 网关状态口 {{gw}}.status(每拍最多问 {} 个账户,环形轮询);阶段列 = 网关推送的 `[链] ` 链状态行(结算单确认 → 查合约 → 资金/持仓轮询走到哪一步);『断开』= 停会话(实例与订阅保留),『移除』= 停实例",
            crate::state::STATUS_ASKS_PER_TICK
        ))
        .color(crate::tokens::semantic::INFO),
    );
    // F4 口径显形(0.2.20):多账户下「谁查谁过滤」此前只能靠猜 —— 实连 S14b 里
    // 账户 B 把勾选改成「期货+期权」,公用合约表仍是 852 条、期权 0 条,用户没有任何
    // 办法判断是自己没生效、还是网关没查到。这里把口径写死成一句(不靠界面猜):
    // 勾选只管**这个网关自己**收录并上抛的部分,公用表是在场各网关的并集。
    ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).color(crate::tokens::semantic::INFO));
    // 状态细节(0.2.19):凡是有话可说的行都给一句(带次数、带秒数、带原因)。
    // 为什么不在状态列里带:Grid 列宽实测会被长文案挤成竖排(s39 实锤)。
    // 为什么不只给告警行:「状态口问不到」与「纯行情账户」也是事实 ——
    // 灰色徽标不给原因,用户就只能猜「是没问到还是没登录」。
    // 只给已装配的行:会话读数属于那个实例,行都「未装配」了还印「掉线 2 次」
    // 就是在替一个不存在的会话说话(状态侧由 `sync_accounts_connected` 一并作废,
    // 这里是不对称时刻——快照还没刷——的第二道闸)。
    // 健康行没有细节(`count_note(…, 0) = None`),所以这一段不会随账户数无谓变长。
    for a in accounts
        .iter()
        .filter(|a| a.stage == crate::state::AccountStage::Assembled && a.status_note.is_some())
    {
        let note = a.status_note.clone().unwrap_or_default();
        let alarm = a.login.is_alarm() || a.login == crate::state::LoginState::QueryStarved;
        let color = if alarm {
            crate::tokens::semantic::ERROR
        } else {
            crate::tokens::semantic::INFO
        };
        ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
        // 0.3.2(Wave E,BD-UI-50):告警 toast **不在这里产生**。产生口只有一处
        // = `UiShared::note_gateway_status`(轮询一拍),判据 e6 钉着这条归属。
        // 在这里弹 = 账户窗没开就没有告警,而 Python 的 AlertEngine 与哪个窗在不在无关。
    }
    // 元账号 md 前置为空 = 永远收不到 tick(添加时被单源纪律清空,须编辑页补填)。
    if let Some(src) = accounts.iter().find(|a| a.gateway == md)
        && src.form.md_addr.trim().is_empty()
    {
        ui.label(
            egui::RichText::new(format!(
                "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情不会出数",
                src.gateway,
                row_label
                    .get(&src.gateway)
                    .cloned()
                    .unwrap_or_else(|| src.account.clone())
            ))
            .color(crate::tokens::semantic::WARN),
        );
    }
}

/// 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):『添加账户』与
/// 『编辑 <账户>』开**同一个弹窗的两种模式**;非 modal(不阻塞主窗与行情观察)。
/// 账号/前置全部界面手工输入,插件不读文件;密码位掩码;**无订阅字段** ——
/// 用户裁定:订阅走下单面板代码回车或查询合约窗勾选。
/// 关闭(『取消』或窗 ×)= **放弃未保存**(规划 Q24);被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
/// 账户表单的标签列(0.4.1 九期 BD-UI-62,用户裁定「这几个字段全部放成一列,数值也放成一列,
/// 每一个字段一行」):标签占固定宽,数值列因此对齐成一列。
///
/// 为什么不用 `Grid`:0.2.17 实锤(s39 同一段)—— 表单 Grid 的列宽在测量期被「窗宽 / 列数」
/// 夹死(实测每格 ≈120px),`desired_width` 永不生效,一根真实前置 `tcp://182.254.242.81:30001`
/// 只显示出 "tcp:/"。固定宽的标签格 + 同一行剩下的宽度给数值,两头都不夹。
const ACCT_FORM_LABEL_W: f32 = 86.0;

fn acct_form_label(ui: &mut egui::Ui, text: &str) {
    ui.scope_builder(
        egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
            ui.cursor().min,
            egui::vec2(
                ACCT_FORM_LABEL_W,
                ui.text_style_height(&egui::TextStyle::Body),
            ),
        )),
        |ui| {
            ui.label(text);
        },
    );
}

fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
    let editing: Option<crate::state::AccountRow> =
        shared.editing_account().and_then(|g| shared.account(&g));
    let editing_on = editing.is_some();
    let md = shared.md_source();
    let locked = shared.accounts_locked();
    let mut form: crate::state::CtpForm = shared.ctp_form();
    let mut alias = shared.edit_alias();
    // 被拦原因上屏(形制校验 / 清册守卫 / 装配被拒):弹窗不关(规划 U3 / Q26)。
    if let Some(e) = shared.edit_error() {
        ui.label(egui::RichText::new(format!("拦下:{e}")).color(crate::tokens::semantic::ERROR));
    }
    // 0.2.17 布局口径:表单用 horizontal 行不用 Grid —— Grid 列宽在测量期被
    // 「窗宽 / 列数」夹死(实测每格 ≈120px),`desired_width` 永不生效,
    // 一根真实前置 `tcp://182.254.242.81:30001`(30 字符)只显示出 "tcp:/"。
    // 身份行:别名(仅显示用,不参与寻址,规划 Q3)+ broker + td 前置 + td 用户。
    // 编辑模式 broker / td 用户**只读**(规划 Q25:它们决定派生注册名,就地改 = 孤儿实例 + 账本错配)。
    // 0.4.1 九期 BD-UI-62:一字段一行(原来四个字段挤在一行,窄窗里数值列互相挤)。
    ui.horizontal(|ui| {
        acct_form_label(ui, "别名");
        if ui
            .add(
                egui::TextEdit::singleline(&mut alias)
                    .hint_text("显示用,可空")
                    .desired_width(120.0),
            )
            .changed()
        {
            shared.set_edit_alias(alias.clone());
        }
    });
    ui.horizontal(|ui| {
        acct_form_label(ui, "broker");
        ui.add_enabled_ui(!editing_on, |ui| {
            ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
        });
    });
    ui.horizontal(|ui| {
        acct_form_label(ui, "td 前置");
        ctp_edit(
            ui,
            shared,
            &mut form.td_addr,
            "tcp://host:40001",
            CtpField::TdAddr,
        );
    });
    ui.horizontal(|ui| {
        acct_form_label(ui, "td 用户");
        ui.add_enabled_ui(!editing_on, |ui| {
            ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
        });
    });
    if let Some(r) = &editing {
        // 只读口径说明(规划 Q25):派生注册名由 broker + 账号决定,就地改会留下
        // 跑着旧名的孤儿实例(svctl 侧无改名口),账本里的归属也会错配。
        ui.label(
            egui::RichText::new(format!(
                "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
                r.gateway
            ))
            .color(crate::tokens::semantic::WARN),
        );
    }
    ui.horizontal(|ui| {
        acct_form_label(ui, "td 密码");
        ctp_edit(
            ui,
            shared,
            &mut form.td_password,
            "密码",
            CtpField::TdPassword,
        );
    });
    ui.horizontal(|ui| {
        acct_form_label(ui, "AppID");
        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
    });
    ui.horizontal(|ui| {
        acct_form_label(ui, "AuthCode");
        ctp_edit(
            ui,
            shared,
            &mut form.td_auth_code,
            "授权码",
            CtpField::AuthCode,
        );
    });
    // 单源纪律(规划 Q12):编辑模式下「非行情源账户 + 行情源已锁定」→ md 位置灰。
    // 它当不了源,填了也会在装配侧被清空;要换源须先移除在场账户(规划 D3)。
    let md_editable = match &editing {
        None => true,
        Some(r) => !locked || r.gateway == md,
    };
    // md 账号 / 密码**不再单列**(0.4.1 九期 BD-UI-62,用户裁定「MD 的那个账号和密码不要单独列了,
    // 默认的就是不用登录嘛,我们就不用填」)。口径与 Python 一致:Python 侧 CTP 配置只有
    // 一套账号/密码(`trader/utility.py:339` 一带,`appID` 那一格),行情会话复用同一套凭据 ——
    // 全仓 grep 不到任何 `md_user` / `md_password`。我们的 `CtpForm.md_user/md_password` 因此
    // 退回成「装配侧内部字段」:表单不给填,留空 = 回落 td 凭据(与 Python 同一行为)。
    ui.horizontal(|ui| {
        acct_form_label(ui, "md 前置");
        ui.add_enabled_ui(md_editable, |ui| {
            ctp_edit(
                ui,
                shared,
                &mut form.md_addr,
                "tcp://host:40011",
                CtpField::MdAddr,
            );
        });
    });
    if !md_editable {
        ui.horizontal(|ui| {
            acct_form_label(ui, "");
            ui.label(
                egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
                    .color(crate::tokens::semantic::WARN),
            );
        });
    }
    // 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。勾选变更立刻回写共享表单
    // —— 与 ctp_edit 同一口径,否则下一帧从 shared 重读就把这次点击丢了。
    ui.horizontal(|ui| {
        ui.label("收录勾选:");
        let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
        let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
        if fut || opt {
            shared.set_ctp_form(form.clone());
        }
        ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
    });
    // F4 口径(0.2.20,与账户面板底部同一句 —— 共用 `TXT_PRODUCT_CLASS_SCOPE` 常量)。
    // 公用合约表是在场各网关的并集:从账户改了勾选、行情源账户没改,表就不会多一行期权。
    ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).weak());
    // 只读回带区(规划 U9):勾选是**意图**,网关回带才是**事实** ——
    // 旧口径 `ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
    if let Some(r) = &editing {
        ui.horizontal(|ui| {
            ui.label("网关实际收录(只读回带):");
            ui.label(if r.effective_classes.is_empty() {
                "—(尚未装配或无回带)".to_string()
            } else {
                classes_label(&r.effective_classes)
            });
            ui.label("装配/登录状态(只读):");
            ui.label(format!(
                "{}{}",
                r.status_label(),
                r.fail_note
                    .as_ref()
                    .map(|n| format!(" —— {n}"))
                    .unwrap_or_default()
            ));
        });
    }
    // 操作行(规划 §3.3 四态):『保存』只写清册;『保存并连接』= 保存 + 入列一条 Connect 令;
    // 『取消』= 放弃未保存(与窗 × 同口径,规划 Q24)。被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
    if shared.account_edit_dirty() {
        ui.label(
            egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
                .color(crate::tokens::semantic::WARN),
        );
    }
    ui.horizontal(|ui| {
        if ui.button("保存").clicked() {
            match save_account_draft(shared, &form, &alias, false) {
                Ok(()) => shared.end_account_edit(),
                Err(e) => {
                    shared.log_line(format!("[账户] 保存被拦:{e}"));
                    shared.set_edit_error(Some(e));
                }
            }
        }
        if ui.button("保存并连接").clicked() {
            match save_account_draft(shared, &form, &alias, true) {
                Ok(()) => shared.end_account_edit(),
                Err(e) => {
                    shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
                    shared.set_edit_error(Some(e));
                }
            }
        }
        if ui.button("取消").clicked() {
            shared.end_account_edit();
        }
        ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
    });
}

/// 账户清册里的派生名集合(账本标注用,每表算一次,不在每格里克隆清册)。
fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
    shared
        .accounts()
        .iter()
        .map(|a| a.gateway.clone())
        .collect()
}

/// 账本行的网关标注(0.2.18 Commit B,规划 U8):该网关已不在账户清册 = **已摘除账户的残留行**。
///
/// 摘除不清账本(柜台侧不受影响,重加该账户后由轮转查询重建视图 —— F5 实连复测:
/// 摘除后账本 81/29/44/2 原封不动),但界面必须说清这一行属于谁:旧口径零标注,
/// 用户会把已摘除账户的委托当成在场账户的。
/// 口径边界:**清册为空 = 不标注**(没有账户配置面时谈不上「摘除」,纯账本巡回钉 s27 即此形);
/// 资金格不标注(那里的网关位是账户来源标签,`ctp` 这类历史字面量不在清册口径内 —— 刻意划界)。
fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
    if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
        gateway.to_string()
    } else {
        format!("{gateway}(已摘除)")
    }
}

/// 产品类短标签(`"1"` → 期货,`"2"` → 期权):勾选意图与网关回带事实并排显示用。
fn classes_label(classes: &[String]) -> String {
    if classes.is_empty() {
        return "—".to_string();
    }
    classes
        .iter()
        .map(|c| match c.as_str() {
            "1" => "期货",
            "2" => "期权",
            other => other,
        })
        .collect::<Vec<_>>()
        .join("+")
}

/// 『连接』入口的唯一出令处(四态分离:一条令做完 `svctl.start` + `{gw}.configure`)。
///
/// 入列同时就地置「装配中」:插件出队时也会置(幂等),但**入列到出队之间**若不置,
/// 行上的『连接/重试』钮还亮着 = 可重复入列(规划 Q7 防重入的界面侧一半)。
fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
    shared.push_cmd(UiCmd::Connect {
        gateway: gateway.to_string(),
        form: form.clone(),
    });
    shared.note_connect_sent(gateway);
    shared.log_line(format!(
        "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
        gateway
    ));
}

/// 配置弹窗『保存』/『保存并连接』的共同路径:**校验 → 写清册 →(可选)入列一条 Connect 令**。
///
/// - 编辑模式:覆盖该账户留档 + 别名(broker/账号只读 → 派生名不变,规划 Q25)。
/// - 添加模式:派生建行(别名随行入册,唯一性在 `add_account` 守卫里)+ 单源定源(规划 §5.C / D3)。
///
/// 被拦返回原因字符串:调用方**不关弹窗**,把原因写进 `edit_error` 显形(规划 Q26)。
fn save_account_draft(
    shared: &UiShared,
    form: &crate::state::CtpForm,
    alias: &str,
    connect: bool,
) -> Result<(), String> {
    form.validate()?;
    if let Some(gw) = shared.editing_account() {
        // 编辑目标必须还在清册(弹窗非 modal:期间该行可能被摘除)。
        if shared.account(&gw).is_none() {
            return Err(format!(
                "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
            ));
        }
        shared.update_account_form(&gw, form.clone());
        if !shared.set_account_alias(&gw, alias) {
            return Err(format!(
                "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
            ));
        }
        shared.log_line(format!(
            "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
        ));
        if connect {
            push_connect(shared, &gw, form);
        }
        return Ok(());
    }
    let mut row = crate::state::AccountRow::derive(form)?;
    row.alias = alias.trim().to_string();
    let gateway = row.gateway.clone();
    let account = row.account.clone();
    shared.add_account(row.clone())?;
    // 单源纪律在「保存」这一刻定源(规划 §5.C / D3):无源且无账户在场 → 新账户就是元账号
    // (md 前置保留 —— 清掉它等于这个账户自己也永远收不到 tick);
    // 已有源或已有账户在场 → 按纯 TD 装配(md 位清空,**留档与下发同一份**,消 U4)。
    let md = shared.md_source();
    let mut f = row.form.clone();
    if md.is_empty() && !shared.accounts_locked() {
        match shared.set_md_source(&gateway) {
            Ok(()) => {
                if f.md_addr.trim().is_empty() {
                    shared.log_line(format!(
                        "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
                    ));
                } else {
                    shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
                }
            }
            Err(e) => {
                shared.log_line(format!("[账户] {e}"));
                f.md_addr.clear();
                f.md_user.clear();
                f.md_password.clear();
            }
        }
    } else {
        f.md_addr.clear();
        f.md_user.clear();
        f.md_password.clear();
        if md.is_empty() {
            shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
        }
    }
    shared.update_account_form(&gateway, f.clone());
    // **四态分离:『保存』到此为止** —— 不 start、不 configure(规划裁定)。
    shared.log_line(format!(
        "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
    ));
    if connect {
        push_connect(shared, &gateway, &f);
    }
    Ok(())
}

/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
    let mut q = shared.contract_query();
    ui.horizontal(|ui| {
        ui.label("代码或交易所(留空 = 全部):");
        if ui
            .add(
                egui::TextEdit::singleline(&mut q)
                    .hint_text("cu 或 SHFE")
                    .desired_width(200.0),
            )
            .changed()
        {
            shared.set_contract_query(q.clone());
        }
    });
    let rows = shared.contracts(&q);
    // 表头单源(0.2.6 审计修:空态分支曾误写另一套表头「代码/…/最后价」,
    // 与非空分支「选(代码)/…/最小变动」不一致——列语义两处必须同源)。
    const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59,同未成交口径)。
    // 0.2.29 那条审计修仍然成立:空态与非空态必须走同一条路(同一 id_salt、同一列语义),
    // 原来空态分支走无列线的入口、非空走有列线的入口,同一张表在空/非空之间切换时形制会变。
    let selected = shared.selected_contracts();
    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
    // 行溢出才出滚动条。
    ui.horizontal(|ui| {
        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
            if selected.is_empty() {
                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
            } else {
                // en_symbol 勾选集 → CTP 裸代码订阅(ask {gw}.subscribe 收裸代码)
                let symbols: Vec<String> = selected
                    .iter()
                    .map(|k| {
                        k.split_once('.')
                            .map(|(_, s)| s.to_string())
                            .unwrap_or_else(|| k.clone())
                    })
                    .collect();
                match subscribe_target(shared) {
                    Ok(gw) => {
                        shared.push_cmd(UiCmd::CtpSubscribe {
                            gateway: gw.clone(),
                            symbols,
                        });
                        shared.log_line(format!(
                            "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
                            selected.len(),
                            gw
                        ));
                    }
                    Err(e) => shared.log_line(format!("[订阅] {e}")),
                }
            }
        }
    });
    // 网格线:这张表曾经是「全仓唯一开网格线的表」(Python 侧只有合约表没调
    // `setShowGrid(False)`:widget.py:2423 / 2621 建表时没关,交易表在 367 关了)。
    // 0.4.1 五期 BD-UI-58 按用户裁定反过来:网格线是**全部表**的固定形制,这条 Python
    // 差异从此不再成立(记死在 CHANGELOG / DESIGN / README)。
    pinned_table(ui, "contract_grid", &CONTRACT_HEADERS, |body, row_h| {
        const RIGHT_COLS: [usize; 2] = [3, 4];
        for c in &rows {
            let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
            let on = selected.contains(&key);
            // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
            // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
            // 行选中(动作归行,不归某一列)。
            let row_sel = shared.row_selected("contracts", &key);
            // key/on 都是行内局部:格延迟画笔要活到 show,必须自带所有权 ——
            // 在 rep_row 闭包外准备 key 克隆与 on 拷贝,各格自己再取。
            let key_owned = key.clone();
            let on_owned = on;
            body.row(row_h, row_sel, |r| {
                macro_rules! cell {
                    ($make:expr, $col:expr) => {{
                        let key = key_owned.clone();
                        r.cell(move |ui, ctx| {
                            let (cell_r, inner_r) =
                                pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
                            let hit =
                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
                            if hit {
                                shared.select_row("contracts", &key.clone());
                            }
                        });
                    }};
                }
                // 勾选框(首格):`changed()` 在勾选框自己的 Response 上,动作就地取。
                {
                    let key = key_owned.clone();
                    let mut on = on_owned;
                    r.cell(move |ui, _ctx| {
                        if ui.checkbox(&mut on, key.clone()).changed() {
                            shared.toggle_contract_selection(&key);
                            shared.select_row("contracts", &key);
                        }
                    });
                }
                cell!(|ui| ui.label(c.exchange.as_str().to_string()), 1);
                cell!(|ui| ui.label(c.name.clone()), 2);
                cell!(|ui| ui.label(crate::format::volume(c.size)), 3);
                cell!(|ui| ui.label(crate::format::price_tick(c.price_tick)), 4);
            });
        }
    });
}

/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
enum CtpField {
    Broker,
    TdAddr,
    TdUser,
    TdPassword,
    AppId,
    AuthCode,
    MdAddr,
}

/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
    let is_secret = matches!(field, CtpField::TdPassword | CtpField::AuthCode);
    let mut w = egui::TextEdit::singleline(val)
        .hint_text(hint)
        // 0.2.17:输入位加宽(旧 110 装不下一根 CTP 前置 `tcp://182.254.242.81:30001`
        // = 30 字符,用户看不见自己填的前置,也无法核对端口;快照钉 s39 前后对照)。
        .desired_width(210.0);
    if is_secret {
        w = w.password(true);
    }
    if ui.add(w).changed() {
        let mut f = shared.ctp_form();
        match field {
            CtpField::Broker => f.broker_id = val.clone(),
            CtpField::TdAddr => f.td_addr = val.clone(),
            CtpField::TdUser => f.td_user = val.clone(),
            CtpField::TdPassword => f.td_password = val.clone(),
            CtpField::AppId => f.td_app_id = val.clone(),
            CtpField::AuthCode => f.td_auth_code = val.clone(),
            CtpField::MdAddr => f.md_addr = val.clone(),
        }
        shared.set_ctp_form(f);
    }
}

/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
///
/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
    let Some(c) = cell else {
        ui.label("(K线无格:等 dm 合成)");
        return;
    };
    if c.bars.is_empty() {
        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
        return;
    }
    // 柱时间按北京钟面给图:egui_charts 0.2 的 Bar 只吃 DateTime<Utc> 且按
    // UTC 钟面画刻度 —— 用「换到 +08 再标回 UTC」的重标定,让横轴刻度显示
    // 北京钟面(数据面存 UTC 纳秒;重标定只挪刻度,不挪序)。
    let mut data = egui_charts::model::bar::BarData::new();
    for b in &c.bars {
        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
            .with_timezone(&beijing_tz())
            .with_timezone(&chrono::Utc);
        data.push(egui_charts::model::bar::Bar::new(
            dt,
            b.open_price,
            b.high_price,
            b.low_price,
            b.close_price,
            b.volume,
        ));
    }
    ui.label(format!(
        "K线 {}@{}s({} 根)",
        c.symbol,
        c.interval,
        c.bars.len()
    ));
    // 0.2.30(BD-UI-46):图表色板不再自带它那套 TradingView 令牌。
    // egui_charts 0.2 的 `ChartConfig::default()` 读的是它自己的 RON `DESIGN_TOKENS`
    // (bullish `#26a69a` 青 / bearish `#F23645` 红)——**涨跌方向正好和我们的
    // 红涨绿跌相反**,而且它不读 egui `Visuals`,界面换什么主题都影响不到它。
    // 所以这里显式覆盖:涨 = RISE(红)、跌 = FALL(绿),底走页面底、网格走 `border`
    // 描边、文字走正文色,让 K 线和界面是一家人(交接报告 §3 Wave B 的「ChartConfig
    // 显式覆盖」项;0.4.1 四期 BD-UI-57 把这三支从 qdark 换成 Nova 同源)。
    let cfg = egui_charts::config::ChartConfig {
        bullish_color: crate::tokens::semantic::RISE,
        bearish_color: crate::tokens::semantic::FALL,
        background_color: crate::tokens::shadcn::BG1,
        grid_color: crate::tokens::shadcn::BORDER,
        text_color: crate::tokens::shadcn::TEXT1,
        ..egui_charts::config::ChartConfig::default()
    };
    let mut chart = egui_charts::widget::Chart::with_config(data, cfg).visible_bars(60);
    chart.set_symbol(&c.symbol);
    chart.set_timeframe_label(&format!("{}s", c.interval));
    chart.enable_tracking_mode();
    let _ = chart.show(ui);
}

#[cfg(test)]
mod tests {
    use super::*;

    // 表头带**派生规则**钉(BD-UI-41,0.2.30 收缩成一套主题):
    // Chrome 的每个角色必须取到令牌里那一档,不许自己挑。
    // (色板原值本身由 `tests/ui_kittest.rs` 的 `u1_palette_matches_source` 钉 ——
    //  色值字面量只留在测试侧,src 侧不写 `from_rgb`。)
    // 原来这里还有一条「不得与金融深色那一列混同」:金融深色已随主题面删除,
    // 判据对象不存在,删掉,不留一条空转的断言。
    // 0.4.0:tab 那三档派生判据随 `tab_label` 一起撤除 —— tab 形态归 dock,
    // 其样式来自同一份 `visuals`(v5 钉住 `Style::from_egui` 这条派生路径)。
    #[test]
    fn chrome_derives_from_token_palette() {
        let v = crate::tokens::visuals();
        let ch = Chrome::of(&v);
        // 0.4.1 四期(BD-UI-57):旧口径 `line` 一支兼做「带底 + 描边」(qss:1959-1970
        // `QHeaderView::section { background #455364 }` 与描边同色)。Nova 把两者拆开 →
        // 两条断言各钉一支,谁也不许兼谁的活。
        assert_eq!(ch.line, crate::tokens::shadcn::BORDER);
        assert_eq!(ch.band_fill, crate::tokens::shadcn::BG4);
        assert_eq!(ch.header_text, crate::tokens::shadcn::TEXT1);
    }

    // 日志入环打戳钉(BD-UI-41):日志表格化后「时间」列的事实源是 `LogRing`,
    // 打戳必须与表内时间列(`fmt_dt`)同一个口径 = 北京 +08,不能跟本机时区走
    // (本机不是 +08 时,口径错 = 日志与成交表差几小时,交易员对不上账)。
    #[test]
    fn log_entries_stamp_beijing_wall_clock() {
        let mut ring = crate::state::LogRing::new(4);
        ring.push("[ops] 一行");
        let e = &ring.entries()[0];
        assert_eq!(e.text, "[ops] 一行", "正文不得被时间戳污染");
        assert_eq!(e.at.len(), 8, "时间戳必须是 HH:MM:SS(8 字符),实测 {}", e.at);
        for seg in e.at.split(':') {
            assert!(seg.parse::<u32>().is_ok(), "时间戳有非数字段:{seg}");
        }
        let want = chrono::Utc::now()
            .with_timezone(&beijing_tz())
            .format("%H:%M:%S")
            .to_string();
        let secs = |s: &str| -> i64 {
            s.split(':')
                .map(|x| x.parse::<i64>().unwrap())
                .fold(0, |acc, v| acc * 60 + v)
        };
        let (a, b) = (secs(&e.at), secs(&want));
        let d = (a - b).abs();
        // 允许跨秒抖动,并容忍跨午夜(差值接近 86400 即同一口径的另一侧)。
        assert!(
            d <= 2 || d > 86_398,
            "日志时间戳必须走北京 +08:实测 {a} vs 期望 {b}"
        );
        // 旧门面不变:`snapshot()` 仍只给正文(测试与巡回取证靠它)。
        assert_eq!(ring.snapshot(), vec!["[ops] 一行".to_string()]);
    }

    // 表内时间列北京钟面钉(0.2.13):数据面存 UTC 纳秒,展示换回 +08 ——
    // 北京 09:30:01 入账后是 UTC 01:30:01,fmt_dt 必须显示 09:30:01,
    // 不是 01:30:01(旧实现的 UTC 直显,交易员看差 8 小时)。
    #[test]
    fn fmt_dt_shows_beijing_wall_clock() {
        let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
            ek_data::time::civil_to_nanos(ek_data::time::Civil {
                year: 2026,
                month: 4,
                day: 16,
                hour: 1,
                minute: 30,
                second: 1,
                nanos: 0,
            }),
        );
        // 真值自洽:UTC 01:30:01 ↔ 北京 09:30:01(双向换算回原钟面)
        let beijing = expect.with_timezone(&beijing_tz());
        assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
        // 再来一个跨日钉:北京 00:00:30 = UTC 前日 16:00:30(同 UTC 日,
        // 不同北京日 —— 旧实现会把这根显示成 16:00:30)
        let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
            year: 2026,
            month: 4,
            day: 15,
            hour: 16,
            minute: 0,
            second: 30,
            nanos: 0,
        });
        // 0.3.1(Wave D):格式从 `HH:MM:SS` 收成 `HH:MM:SS.mmm`(照 Python `TimeCell`),
        // 时区口径没动 —— 这条钉的仍是「北京墙钟」这件事,不是小数位。
        assert_eq!(crate::format::time_hms_millis(Some(ns)), "00:00:30.000");
        // None → 占位
        assert_eq!(crate::format::time_hms_millis(None), "-");
    }
}