Skip to main content

ek_ui/
render.rs

1//! 唯一渲染入口(L2):真实 eframe App 与 kittest 建构走同一个 `render_body`。
2//!
3//! 布局 v7(0.4.1 BD-UI-54:拆停靠库、回归原生容器;蓝本 = Python 主窗 mainwindow.py):
4//! **TopBottomPanel(菜单) → TopBottomPanel(行情带) → SidePanel(下单) →
5//! CentralPanel(数据区 2×2 = 薄 QSplitter 两刀) → Window 弹窗**。
6//! - 行情带 = 六所固定 tab 条(`state::MARKET_ORDER`,用户裁定「每个交易所都要一个」;
7//!   标签 = 交易所**中文名**,条在**表之下**且先给它预留高度不许外溢 —— 九期 BD-UI-62)
8//!   加当前所的行情表;每个所都有自己的表与空态。只有 tick 表,没有按所的滚动表
9//!   —— 唯一源 = `eTick.` 订阅流,不从 bar 回。行内任意格单击 = 带入下单面板
10//!   (ek 增值,方案钉:盘口四列带方向,买1 价/量 → 卖,卖1 价/量 → 买,价格列
11//!   顺带填价,其余列方向保持默认 —— 0.2.1 `import_tick`),双击 = 弹 K线窗。
12//! - 下单侧(`SidePanel::left`,缺省 300 ≈ Python `setFixedWidth(300)`):下单面板
13//!   (Python TradingWidget 等价:9 字段 + 委托 + 全撤;表单 = 网格,按钮/提示语都是
14//!   网格行;网格后置 err label 由 ScrollArea 吸收 sizing 退让,BD-UI-27)+
15//!   盘口(买5卖5,Python TradingWidget 内序:字段钮 → 盘口)。
16//! - 数据区组容器:委托|未成交 / 成交 / 持仓 / 资金|日志 四组(Python 四个 QTabWidget
17//!   等价,tab 条 = 自绘 `tab_label`),2×2 两刀 = `crate::splitters`(份额可拖,
18//!   态在 `shared.data_split`,恢复布局回 50:50);撤单钮仅活动行,既有 s11 钉口径;
19//!   委托行双击 = 撤单,Python OrderMonitor 等价;格内宽表走横向滚动,D7 口径)。
20//! - 弹窗两式(BD-UI-32):运维窗(菜单「运维」:插件/服务/依赖;0.2.10 起
21//!   流控查询钮随 sim 退场撤除)、
22//!   CTP 配置窗(菜单「网关」,CTP 插件自己的界面)= egui::Window 依附主窗;
23//!   查询合约窗(菜单「查询合约」:查询 + 勾选 + 订阅钮,Python ContractManager
24//!   等价)、K线窗(双击弹出,按合约多开)= 独立 OS 窗(show_viewport_immediate,
25//!   原生最小化/最大化/关闭,可拖出主窗;后端不支持多窗自动回退 egui::Window,
26//!   kittest 走回退面断言)。
27//! - 订阅两入口(用户裁定,网关窗不定死订阅):下单面板代码框**回车**订阅
28//!   (Python returnPressed 等价);查询合约窗**勾选订阅**。合约数据 =
29//!   `eContract.` 自动回流(ek-ctp 登录后自动查询,UI 只收不催)。
30//! - 回灌表单与票据文本框已退役(界面面);总线口(`UiCmd::Reload` 等)保留。
31//!
32//! kittest 断言面:各单元格独立 Label(`get_by_label` 直查);操作走
33//! `push_cmd` 入队(ui 插件 eTimer 辞行出队),确认状态就地翻位,零自制通道。
34//! 隔离红标:行状态含 Quarantin(快照扫描,BD-UI-17)。
35
36use ek_data::constant::{Direction, Offset, OrderType};
37use ek_station::SVCTL_NAME;
38use ek_station::wire::PlugRow;
39
40use crate::plugin::UI_NAME;
41use crate::state::{
42    BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, beijing_tz, is_quarantined,
43};
44
45/// 取消待确认按钮标签。
46pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
47/// 下单提交按钮标签(v4 = Python「委托」钮)。
48pub const BTN_SEND_ORDER: &str = "委托";
49/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
50pub const BTN_CANCEL_ALL: &str = "全撤";
51/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
52pub const BTN_CANCEL_ORDER: &str = "撤单";
53/// 账户面板:添加账户钮(派生注册名 → svctl.start + {gw}.configure)。
54pub const BTN_ACCOUNT_ADD: &str = "添加账户";
55/// 菜单项:运维(弹运维窗)。
56pub const MENU_OPS: &str = "运维";
57/// 菜单项:账户(弹账户面板;多账号面入口,规划 §5.B。旧「网关」单账号窗
58/// 已退役收纳进账户面板的添加/编辑表单,旧常量随之删除)。
59pub const MENU_ACCOUNTS: &str = "账户";
60/// 菜单项:帮助。
61pub const MENU_HELP: &str = "帮助";
62/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
63pub const MENU_CONTRACTS: &str = "查询合约";
64/// 菜单项 → 弹窗入口钮(BD-UI-31 一键直达:菜单钮即入口,一步弹窗,
65/// 不再挂下拉层;入口钮标签 = 菜单名本身)。
66/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
67pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
68/// 运维窗:装配显形手动重问钮(0.5 M3 —— 管线读数改吃事件后唯一的手动口子)。
69pub const BTN_PIPELINE_REFRESH: &str = "重问管线";
70/// 运维窗「配置托底」节:段目录刷新钮(0.6.0 环节8)。
71pub const BTN_CFG_REFRESH: &str = "刷新段落目录";
72
73/// F4 口径句(0.2.20,D-UI-F4-SCOPE):账户面板底部与配置弹窗**共用这一条常量**。
74/// 两处各写一遍字面量 = 将来不同式的坑(s57 数的是"两处都在")。
75pub const TXT_PRODUCT_CLASS_SCOPE: &str = "收录勾选只管该网关自己收录并上抛的合约;公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)。改勾选须『连接/重试』才下发到网关。";
76
77fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
78    if armed_on == Some(name) {
79        format!("确认移除 {name} ?")
80    } else {
81        format!("移除 {name}")
82    }
83}
84
85/// 订阅路由(规划 §5.C):行情订阅一律发往**行情元账号**。
86///
87/// 未指定行情源时回 `Err` —— 不静默发给某个从账号(纯 TD 账号收到订阅会
88/// 恒 `ok:true` 但永远不来 tick,是观测黑洞)。
89fn subscribe_target(shared: &UiShared) -> Result<String, String> {
90    let md = shared.md_source();
91    if md.is_empty() {
92        return Err("未指定行情源:账户面板点某账户的『设为行情源』后再订阅".to_string());
93    }
94    Ok(md)
95}
96
97fn state_text(r: &PlugRow) -> String {
98    r.state.clone()
99}
100
101/// 运维窗「统计」列的颜色(0.4.5,P7d;0.5 M6 加第三个参数)。
102///
103/// 判据按**最坏的那一项**:`0 / 0` → MUTED(这一格的意思是「没事」,不是数据);
104/// 溢出 > 0 → WARN;溢出跨过**这一行自己的**邮箱容量或有任何 panic → ERROR。
105/// 容量必须传进来:0.5 起 ui 覆写了 `mailbox_cap`(10 万格),还按 ek-core 缺省 1024
106/// 判,就会把「ui 丢了 1024 条」染成「溢满一整轮」的红色 —— 而它那一轮是 10 万条。
107/// 公开是为了让这一判据可钉 —— 颜色本身在 kittest 里只能靠形状颜色判,
108/// 把决策抽成纯函数才能直接断言。
109pub fn stat_color(spilled: u64, panics: u64, cap: u64) -> egui::Color32 {
110    if panics > 0 || spilled >= cap {
111        crate::tokens::semantic::ERROR
112    } else if spilled > 0 {
113        crate::tokens::semantic::WARN
114    } else {
115        crate::tokens::semantic::MUTED
116    }
117}
118
119// 0.3.1(Wave D,BD-UI-49):时间列格式收进 `crate::format::time_hms_millis`(`HH:MM:SS.mmm`)。
120// 口径没变:北京时间 +08,数据面时间戳统一存 UTC 纳秒 —— CTP 回报的自然日本就是北京钟面,
121// 入账按 +08 换算存 UTC,展示面换回来,表内时间列与交易员作息同钟面,且与日志同源。
122
123// 展示面统一时区 = `crate::state::beijing_tz`(0.2.27 提到 state:日志入环打戳
124// 与表格时间列必须同一个口径,一份实现两处共用)。这里不留 doc comment ——
125// 它后面没有 item,clippy `empty_lines_after_doc_comment` 正是钉这种。
126
127// ---- BD-UI-31(R3)行高亮:由表格层承担(0.2.29 BD-UI-44 定口径,0.4.1 BD-UI-55 换库)----
128// 原来这里有两支手绘助手:`paint_row_bg`(行底色 = 各格响应矩形并集 + 硬编码半透明蓝)
129// 和 `click_cell`(在标签矩形上叠一层点击感应)。两支助手在 BD-UI-44 撤除:底色改吃主题
130// (`visuals.selection.bg_fill` / `widgets.hovered.bg_fill`,整行连续),格可点 = 表格层
131// 自己给的 Response。BD-UI-55 迁 egui_table 之后这条口径没变,只是实现搬到 delegate 的
132// `row_ui`(选中 / 悬停底色,行带全宽)与 `pin_cell`(格点击感应)。行底色仍跟主题
133// (0.2.27 斑马纹同口径:唯一主题下 `visuals.striped = false`,不许硬开)。
134
135/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
136fn order_active(status: ek_data::constant::Status) -> bool {
137    ek_data::constant::ACTIVE_STATUSES.contains(&status)
138}
139
140/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
141/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
142/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
143pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
144    let mut f = shared.order_form();
145    f.symbol = t.symbol.clone();
146    f.exchange = exchange.to_string();
147    match col {
148        8 => {
149            f.direction = Direction::SHORT;
150            f.price = crate::format::price(t.bid_price_1);
151        }
152        9 => f.direction = Direction::SHORT,
153        10 => {
154            f.direction = Direction::LONG;
155            f.price = crate::format::price(t.ask_price_1);
156        }
157        11 => f.direction = Direction::LONG,
158        _ => {}
159    }
160    shared.set_order_form(f);
161}
162
163/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
164/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
165fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
166    if symbol.is_empty() {
167        return None;
168    }
169    shared.tick_rows().into_iter().find(|t| {
170        t.symbol == symbol
171            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
172    })
173}
174
175/// 字号 + 间距 + 控件规格 + 主题视觉(0.2.30 起全部走 `crate::tokens`;主题只有一套
176/// Qt深色,`Theme` 枚举与菜单循环钮已删 —— 裁定 8)。真实 App、独立子窗(immediate
177/// 共享同一 Context)、kittest 三处同源。
178/// 语义色(涨红跌绿 / 多红空绿 / 警告橙 / 错误深红 / 成功深绿 / 次级灰)不在这一层:
179/// 它们在 `tokens::semantic` 里恒定,渲染侧直绘,不随视觉档漂移(金融语义不玩配色)。
180pub fn apply_ui_style(ctx: &egui::Context) {
181    ctx.style_mut(|style| {
182        crate::tokens::apply(style);
183    });
184    ctx.set_visuals(crate::tokens::visuals());
185}
186
187/// 表格与页签的「chrome」配色(表头带、分隔线、tab 头底、选中 tab 下划线)。
188///
189/// 为什么要有这个结构:shadcn 有「表头带底」(`secondary` `#262626`)和「选中 tab 底 +
190/// 下划线」这两个概念,Python 侧对应 qdarkstyle 的 `QHeaderView::section`
191/// (表头带底 `#455364`)与 `QTabBar::tab:top:selected`(选中 tab `#54687A` 底 + 3px
192/// `#259AE9` 下划线),**egui 的 `Visuals` 里没有对应字段**(它只有 `selection.bg_fill`
193/// 一根,且 `selectable_label` 只能整块填充)。所以这一层只能在渲染侧直绘。
194///
195/// 取色口径(审计「多余」条):不新造颜色,全部从主题**已经声明过**的字段里取 ——
196/// 带底取 `widgets.inactive.bg_fill`,描边取 `noninteractive.bg_stroke`,表头文字取
197/// `noninteractive.fg_stroke`,选中 tab 底取 `widgets.hovered.bg_fill`,下划线取
198/// `hyperlink_color`。0.2.30 起主题只有一套,原来「按主题分叉」的那条 match 随之消失。
199///
200/// 0.4.1 四期(BD-UI-57)拆掉一根旧耦合:旧 qdark 里 `BG4` **同时**是表头带底和一切描边
201/// (`noninteractive.bg_stroke` = `#455364` = 带底),所以 `line` 一支两用。Nova 把这两件事
202/// 分成 `secondary` `#262626`(带底)与 `border`(白 10%,一切描边)→ 于是多出 `band_fill`
203/// 这一支,`line` 只管线不管底。
204///
205/// 0.4.0 期间 tab 那四支随「组内二级 tab 条撤除、tab 归 dock」删过一轮;0.4.1
206/// (BD-UI-54)tab 形态回归原生(自绘 `tab_label`),四支原样归位 —— 派生路径
207/// (同一份 `visuals`,`s67` 钉住)不变。
208#[derive(Clone, Copy)]
209struct Chrome {
210    /// 一切分隔线 / 网格线 / 行线 / 窗框(1px)。旧口径这一支兼做表头带底,现已拆开。
211    line: egui::Color32,
212    /// 表头带底 + 未选中 tab 的头底。
213    band_fill: egui::Color32,
214    /// 表头带文字。
215    header_text: egui::Color32,
216    /// 选中 tab 的底。
217    tab_fill: egui::Color32,
218    /// 选中 tab 的文字。
219    tab_text: egui::Color32,
220    /// 选中 tab 的下划线(qss 3px 那支)。
221    tab_underline: egui::Color32,
222}
223
224impl Chrome {
225    /// 从当前 visuals 派生(唯一主题 → 唯一派生规则,不再按主题分叉)。
226    fn of(v: &egui::Visuals) -> Self {
227        Self {
228            line: v.widgets.noninteractive.bg_stroke.color,
229            band_fill: v.widgets.inactive.bg_fill,
230            header_text: v.widgets.noninteractive.fg_stroke.color,
231            tab_fill: v.widgets.hovered.bg_fill,
232            tab_text: v.widgets.hovered.fg_stroke.color,
233            tab_underline: v.hyperlink_color,
234        }
235    }
236}
237
238/// 页签(BD-UI-41,对照 qdarkstyle `QTabBar::tab:top`)。
239///
240/// Python 侧四个区全是 QTabWidget(`mainwindow.py:106-204`,连只有一个 tab 的
241/// 「成交」「持仓」也带 tab 头),qss 给 tab 的形态是:
242///   未选中 `QTabBar::tab:top { background #455364; padding 2px 4px }` +
243///     `!selected { border-bottom: 2px solid #19232D }`(qss:1579 / 1559)
244///   选中   `QTabBar::tab:top:selected { background #54687A;
245///     border-bottom: 3px solid #259AE9; 上圆角 4px }`(qss:1592)
246/// egui 的 `selectable_label` 只有「整块 selection 填充」,画不出下划线,也没有
247/// 「未选中也有头底」这一层。所以这里照 pinned_table 表头带同一套路子:
248/// **控件仍走 selectable_label**(可访问性标签 / 点击 / 键盘都不换),只是随后用
249/// painter 盖上头底 + 下划线,并重画文字(epaint 0.33 没有 z-order,后画的填充
250/// 会盖住先画的文字,所以文字必须重画 —— 与表头带同一处理)。
251fn tab_label(ui: &mut egui::Ui, selected: bool, text: impl Into<String>) -> egui::Response {
252    let ch = Chrome::of(ui.visuals());
253    let text: String = text.into();
254    let resp = ui.selectable_label(selected, text.clone());
255    let r = resp.rect;
256    let radius = ui.visuals().widgets.inactive.corner_radius;
257    let font = ui.style().text_styles[&egui::TextStyle::Button].clone();
258    let p = ui.painter();
259    let (fill, ink, underline) = if selected {
260        (
261            ch.tab_fill,
262            ch.tab_text,
263            egui::Stroke::new(3.0_f32, ch.tab_underline), // qss 的 3px 那支
264        )
265    } else {
266        (
267            ch.band_fill,
268            ch.header_text,
269            egui::Stroke::new(2.0_f32, ch.line), // 未选中 tab 的 2px 下边线
270        )
271    };
272    p.rect_filled(r, radius, fill);
273    p.hline(r.x_range(), r.max.y, underline);
274    p.text(
275        r.center(),
276        egui::Align2::CENTER_CENTER,
277        text.as_str(),
278        font,
279        ink,
280    );
281    resp
282}
283
284/// 普通 Grid 的表头带(BD-UI-41)。
285///
286/// 与 `pinned_table` 的覆盖带同一套路子:头行照常在 grid 里占格(列宽由它量出来,
287/// 这是唯一权威源),随后 painter 盖上带底 + 下边线并重画文字(epaint 0.33 无 z-order,
288/// 填充盖字 → 文字必须重画)。
289///
290/// 为什么这三处不走 `pinned_table`:它们是**自动尺寸浮动窗里的短表**(账户清单 /
291/// 运维插件表 / 服务目录)。0.2.17 实锤(s39):`pinned_table` =
292/// `auto_shrink(false)` 滚动区吃满整块可用高 + 覆盖式表头带,在自动尺寸浮动窗里
293/// 两者凑不出正确高度 → 表行与其后的口径句互相压字。账户行最多几个,不需要固定
294/// 表头滚动面,只要那根带。
295///
296/// 0.2.29(BD-UI-44)把这条边界写死:数据监控表(行情/委托/成交/持仓/日志/合约/资金)
297/// 一律走表格层(0.4.1 BD-UI-55 = egui_table 的 `pinned_table`);自动尺寸浮动窗里的
298/// 短表一律 Grid + 这根带。资金表原来在第三类里,实测它和日志表共用同一块 tab 容器
299/// (不是浮动窗)→ 已并入表格层一侧。
300fn header_band(
301    ui: &mut egui::Ui,
302    heads: &[(egui::Rect, String)],
303    content_right: f32,
304) -> egui::Rect {
305    if heads.is_empty() {
306        return egui::Rect::NOTHING;
307    }
308    let ch = Chrome::of(ui.visuals());
309    // 带的横向范围 = **整行**(这一格 / 这一窗的可用宽),不是表头文字矩形的并集。
310    // 根因(0.2.28 用户实看指出「运维窗表头背景没按整行、最后一列只有文字有背景」):
311    // `Response::rect` 是控件自己的矩形,不是列框(grid.rs:180-193 —— `next_cell`
312    // 给出列框,`align_size_within_rect` 按 LEFT_CENTER 对齐后返回子控件尺寸)。
313    // 表头 label 的 rect 宽 = 文字宽。原来按表头 rect 的并集算带 → 带只盖到
314    // 最后一列**表头文字**的右端,列内比它宽的部分(运维窗『操作』列的两个按钮、
315    // 服务目录的空实例列、资金格右侧)没有带 = 看上去断开一截。
316    // 口径照 Python:QHeaderView 铺满整个表视口;也与 `pinned_table` 那条带一致
317    // (那条铺的是 ScrollArea 的 inner_rect = 整格宽)。
318    let left = ui.max_rect().min.x;
319    // 0.2.30(BD-UI-47):带的右端取「可用宽」与**表体实际右端**的较大者。
320    // 浮动窗是自动尺寸的,而 `header_band` 在 Grid 之后才画 —— 令牌把控件交互宽
321    // 从 egui 缺省 128 收到 40 后,窗内 Grid 的实际宽度比窗的可用宽更宽(s69 实测
322    // 带 640 / 体表 727),带就盖不住最后一列。口径不变:带必须盖住整行。
323    let right = ui.max_rect().max.x.max(content_right);
324    // 带高与表格层**同一个数**(0.4.1 八期 BD-UI-61,用户问「表头的高度都一样吗」):
325    // 表格层带高 = 表头行高 `head_h`(26)+ `clip_rect_margin`(3)= 29;短表层原来取
326    // 「文字高 + 上下各半间距」= 22 → 同一屏里菜单窗的表头比主窗矮 7 px(探针实测 22.0 vs 29.0)。
327    // 现在按同一条公式算,并以**表头行的垂直中心**为带中心 → 带高一致、文字在带里居中。
328    let head_h = table_heights(ui).1;
329    let band_h = head_h + ui.visuals().clip_rect_margin;
330    let mid = heads[0].0.center().y;
331    let top = mid - band_h * 0.5;
332    let bottom = mid + band_h * 0.5;
333    let strip = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom));
334    let p = ui.painter();
335    p.rect_filled(strip, 0.0, ch.band_fill);
336    p.hline(
337        strip.x_range(),
338        strip.max.y,
339        egui::Stroke::new(1.0_f32, ch.line),
340    );
341    let font = ui.style().text_styles[&egui::TextStyle::Small].clone();
342    // 表头文字的锚定线 = **带的垂直中心**(0.4.1 七期 BD-UI-60)。旧口径取 galley 中心
343    // `(top + heads[0].0.max.y)/2`,比带中心高 2.5 px → 文字在带里偏上(实测上隙 4.5 / 下隙 8.5)。
344    let mid_y = (strip.min.y + strip.max.y) * 0.5;
345    for (rect, text) in heads {
346        p.text(
347            egui::pos2(rect.min.x, mid_y),
348            egui::Align2::LEFT_CENTER,
349            text.as_str(),
350            font.clone(),
351            ch.header_text,
352        );
353    }
354    strip
355}
356
357/// 判据侧读口(0.4.1 七期 BD-UI-60):这张表这一拍的「列宽总和」与「补宽目标宽」。
358///
359/// 目标宽 = 所在区域可用宽 − 这张表自己的滚动条占位。判据钉的是「补过之后 Σ ≥ 目标 9 成」
360/// —— 表宽 = 所在区域 100% 这条裁定在 kittest 里唯一不靠猜的读法(列线撤除后,列边界
361/// 在形状表里已经没有落点了)。
362pub fn table_fill_probe(ctx: &egui::Context, table_id: &str) -> (f32, f32) {
363    let fill_id = egui::Id::new(("table_fill", table_id));
364    ctx.data(|d| {
365        (
366            d.get_temp::<f32>(fill_id.with("stored")).unwrap_or(0.0),
367            d.get_temp::<f32>(fill_id).unwrap_or(0.0),
368        )
369    })
370}
371
372/// Grid 短表的网格线(0.4.1 五期 BD-UI-58,用户裁定「网格线在黑色背景下反而显得整齐」
373/// → 表格层与短表层同一口径:每行一根行线、每两列一根列线)。
374///
375/// - 行线 = 这一行内容底沿往下半个 `item_spacing.y`。Grid 的行间距就是 `item_spacing`
376///   (grid.rs:113 / 432),半个间距 = 两行正中;最后一行的行线就是这张表的底边线,
377///   与 `pinned_table` 里「行线画在行底」同一条不变式(那边行间距为 0)。
378/// - 列线 = 第 2..n 列表头矩形的左沿往左半个 `item_spacing.x`。Grid 格内是 LEFT 对齐
379///   (`align_size_within_rect`),控件左沿就是列左沿 → 半个间距正是两列正中。
380/// - 表头行那根下边线由 `header_band` 画,这里不重复画第二根。
381///
382/// `row_bottoms` = 各**表体行**在 `end_row` 前测到的 `ui.min_rect().max.y`。空态(0 行)
383/// 时它是空的:没有行线可画,列线只到表头带底(0.4.1 六期 BD-UI-59 起空态不画提示行,短表空态
384/// = 只有表头那一行)。
385fn grid_chrome(
386    ui: &egui::Ui,
387    band: egui::Rect,
388    heads: &[(egui::Rect, String)],
389    row_bottoms: &[f32],
390) {
391    if !band.is_positive() || heads.is_empty() {
392        return;
393    }
394    let ch = Chrome::of(ui.visuals());
395    let sp = ui.spacing().item_spacing;
396    let p = ui.painter();
397    for &rb in row_bottoms {
398        p.hline(
399            band.x_range(),
400            rb + sp.y * 0.5,
401            egui::Stroke::new(1.0_f32, ch.line),
402        );
403    }
404    // 列线(0.4.1 七期 BD-UI-60,用户裁定「表格里所有的列分割线都去掉,只要行的分割线」):
405    // 短表层这套自绘列线直接撤除,`heads` 从此只用来判「有没有表头」与定带的宽。
406}
407
408/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
409///
410/// v7 框架(0.4.1,BD-UI-54):TopBottomPanel(菜单) → TopBottomPanel(行情带) →
411/// SidePanel(下单)| CentralPanel(数据区 2×2 薄 QSplitter) → 弹窗。
412/// 0.4.0 的整窗 dock 拆除。官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、
413/// CentralPanel 最后加、Window 挂在所有面板之后。
414pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
415    apply_ui_style(ctx);
416    // 0.3.1(Wave D,BD-UI-49)退出确认:关窗先问一句(照 Python `closeEvent`)。
417    // 拦关闭用的是 egui 0.33 的 `ViewportCommand::CancelClose`;用户在窗里点「确认退出」
418    // 才置放行位并重发 `Close` —— 否则确认完也关不掉。
419    let close_requested = ctx.input(|i| i.viewport().close_requested());
420    if close_requested && !shared.exit_confirmed() && !shared.exit_confirm_open() {
421        ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
422        shared.open_exit_confirm();
423    }
424    // 0.3.2(Wave E,BD-UI-50)告警 toast 每帧收口(队列常驻在 UiShared,不是这里的局部变量)。
425    crate::toast::drain_and_show(ctx, shared);
426    if shared.exit_confirm_open() {
427        egui::Window::new("退出确认")
428            .collapsible(false)
429            .resizable(false)
430            .show(ctx, |ui| {
431                ui.label("确认退出 ek-ui?");
432                ui.horizontal(|ui| {
433                    if ui.button("取消").clicked() {
434                        shared.close_exit_confirm();
435                    }
436                    if ui.button("确认退出").clicked() {
437                        shared.confirm_exit();
438                        shared.close_exit_confirm();
439                        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
440                    }
441                });
442            });
443    }
444    // ---- 菜单栏(顶层面板,最先 = 最外) ----
445    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
446        render_menu_bar(ui, shared);
447    });
448
449    // ---- 上带:行情带(全宽独占,可拖分隔条;缺省高 = Python v_splitter 30:70 分带) ----
450    egui::TopBottomPanel::top("market_band")
451        .resizable(true)
452        .default_height(crate::tokens::MARKET_BAND_HEIGHT)
453        .show(ctx, |ui| {
454            // 外层 ScrollArea auto_shrink(false) = resizable 面板吃满前提(BD-UI-27
455            // 口径;s3 实锤:撤掉后空态带塌成一行,2×2 行位全漂移)。内层
456            // pinned_table 自己会收缩适配 → 外层滚动条永不出现。
457            egui::ScrollArea::both()
458                .id_salt("market_band_scroll")
459                .auto_shrink([false, false])
460                .show(ui, |ui| {
461                    render_market_band(ui, shared);
462                });
463        });
464
465    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget setFixedWidth(300)) ----
466    egui::SidePanel::left("order_side")
467        .resizable(true)
468        .default_width(crate::tokens::ORDER_SIDE_WIDTH)
469        .show(ctx, |ui| {
470            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
471            egui::ScrollArea::vertical()
472                .id_salt("order_side_scroll")
473                .auto_shrink([false, false])
474                .show(ui, |ui| {
475                    render_order_panel(ui, shared);
476                    ui.separator();
477                    render_book_frame(ui, shared);
478                });
479        });
480
481    // ---- 下带右侧:数据区 2×2(薄 QSplitter 两刀;份额可拖,态在 UiShared) ----
482    egui::CentralPanel::default().show(ctx, |ui| {
483        render_data_area(ui, shared);
484    });
485
486    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
487    render_windows(ctx, shared);
488}
489
490/// 行情带 = 当前所的行情表 + 六所固定 tab 条(0.4.1 BD-UI-54 回归原生容器;
491/// 「每交易所一格」= 六个 tab,每个 tab 都有自己的表与空态,s73 口径不变)。
492///
493/// 0.4.1 用户裁定(2026-10-22):**tab 条放在表下面**(原来在表上面)。
494/// 这是对 Python 侧 `QTabWidget` 缺省 tabPosition=North 的一处偏离,记 DESIGN。
495/// 判据面不受影响:六所 tab 序钉(V2)与「行情带横跨全宽」钉(s3)取的都是
496/// tab 标签在场 + 格内容左沿/高度关系,与 tab 条在表的哪一侧无关。
497/// 交易所 tab 名 = **中文名**(0.4.1 九期 BD-UI-62,用户裁定「不要显示交易所的代码,
498/// 而是显示交易所的名字」)。叫法照 Python 侧(`xt_gateway.py:1089-1094` 与
499/// `datafeed/ak/futures_member_pos.py:14-19` 的映射:中金所=CFFEX / 上期所=SHFE /
500/// 郑商所=CZCE / 大商所=DCE / 广期所=GFEX,INE = 能源中心)。
501pub fn exchange_tab_name(ex: ek_data::constant::Exchange) -> &'static str {
502    use ek_data::constant::Exchange as E;
503    match ex {
504        E::SHFE => "上期所",
505        E::DCE => "大商所",
506        E::CZCE => "郑商所",
507        E::CFFEX => "中金所",
508        E::GFEX => "广期所",
509        E::INE => "能源中心",
510        // 行情带只有上面六所(`state::MARKET_ORDER`);其余变体回落线上代码。
511        other => other.as_str(),
512    }
513}
514
515fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
516    let active = shared.selected_exchange();
517    let active_ex = crate::state::MARKET_ORDER
518        .iter()
519        .copied()
520        .find(|e| e.as_str() == active)
521        .unwrap_or(crate::state::MARKET_ORDER[0]);
522    // **先给 tab 条留出高度,再让表吃剩下的**(0.4.1 九期 BD-UI-62,用户实看:「表格被下面
523    // 标签给挡住了,只有滚动才能看到」)。根因:表(内层 ScrollArea)先分配 = 把整块面板高
524    // 吃干,tab 条排在它后面就被推到面板底边之外(探针实测 1280×860:面板底 ≈275,
525    // tab 标签落在 y 274..300 = 面板之外,还与数据区「委托」tab 头 278..304 压在一起)。
526    // tab 条是固定条,不该跟表格滚动抢地方。
527    // 预留高度照 `tab_label` 真正用的那一档:`selectable_label` 走 `TextStyle::Button`
528    // + `button_padding`(不是 Small —— 用 Small 会少算,窗口一窄就又开始外溢)。
529    let tab_h = ui.text_style_height(&egui::TextStyle::Button)
530        + ui.spacing().button_padding.y * 2.0_f32
531        + 4.0_f32;
532    let avail = ui.available_size();
533    let table_rect = egui::Rect::from_min_size(
534        ui.cursor().min,
535        egui::vec2(avail.x, (avail.y - tab_h).max(40.0)),
536    );
537    ui.scope_builder(egui::UiBuilder::new().max_rect(table_rect), |ui| {
538        // 表头带的上隙(`clip_rect_margin`)要在子 ui 里留出地方:子 ui 的 clip = 这块
539        // max_rect,若表从 cursor 顶开始画,带顶那 3 px 会被裁掉(带高 26 而不是 29,
540        // s79「所有表头带同一档」当场红)。
541        ui.add_space(ui.visuals().clip_rect_margin);
542        render_market_pane(ui, shared, active_ex);
543    });
544    ui.horizontal(|ui| {
545        for ex in crate::state::MARKET_ORDER {
546            if tab_label(ui, ex == active_ex, exchange_tab_name(ex)).clicked() {
547                shared.set_selected_exchange(ex.as_str());
548            }
549        }
550    });
551}
552
553/// 数据区 2×2 = 上下(顶部:委托|未成交 | 成交;底部:持仓 | 资金|日志),
554/// 两刀都是可拖的薄 QSplitter(份额在 `shared.data_split`,恢复布局回 0.5)。
555fn render_data_area(ui: &mut egui::Ui, shared: &UiShared) {
556    let split = shared.data_split();
557    // 0.4.1 九期 BD-UI-62:2×2 改走 `splitters::quad`(一根贯穿横线 + 一根贯穿竖线)。
558    // 旧口径 = `split_v` 上下分完再在每排里各来一刀 `split_h` → 「竖刀」实为两条独立线段,
559    // 悬 / 拖只有被碰的那半亮(用户实看)。份额仍是同一对 `DataSplit { row, col }`。
560    crate::splitters::quad(
561        ui,
562        egui::Id::new("ek_data_quad"),
563        split.row,
564        split.col,
565        |row| {
566            shared.set_data_split(crate::state::DataSplit {
567                col: split.col,
568                row,
569            });
570        },
571        |col| {
572            shared.set_data_split(crate::state::DataSplit {
573                col,
574                row: split.row,
575            });
576        },
577        |ui| render_topleft_group(ui, shared),
578        |ui| render_trades_group(ui, shared),
579        |ui| render_positions_group(ui, shared),
580        |ui| render_bottomright_group(ui, shared),
581    );
582}
583
584/// 委托|未成交 组(Python tab_widget1:两 tab 同一容器)。
585fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
586    let tab = shared.topleft_tab();
587    ui.horizontal(|ui| {
588        if tab_label(ui, tab == TopLeftTab::Orders, TopLeftTab::Orders.label()).clicked() {
589            shared.set_topleft_tab(TopLeftTab::Orders);
590        }
591        if tab_label(ui, tab == TopLeftTab::Active, TopLeftTab::Active.label()).clicked() {
592            shared.set_topleft_tab(TopLeftTab::Active);
593        }
594    });
595    render_orders_table(ui, shared, tab == TopLeftTab::Active);
596}
597
598/// 资金|日志 组(Python tab_widget4;Account 分支 = 0.4.0 修好的「空态带表头」版)。
599fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
600    let tab = shared.bottomright_tab();
601    ui.horizontal(|ui| {
602        if tab_label(
603            ui,
604            tab == BottomRightTab::Account,
605            BottomRightTab::Account.label(),
606        )
607        .clicked()
608        {
609            shared.set_bottomright_tab(BottomRightTab::Account);
610        }
611        if tab_label(
612            ui,
613            tab == BottomRightTab::Logs,
614            BottomRightTab::Logs.label(),
615        )
616        .clicked()
617        {
618            shared.set_bottomright_tab(BottomRightTab::Logs);
619        }
620    });
621    match tab {
622        BottomRightTab::Account => render_funds_pane(ui, shared),
623        BottomRightTab::Logs => render_logs(ui, shared),
624    }
625}
626
627// 0.4.1(BD-UI-54):数据区组的容器 = 薄 QSplitter 的两块内容区(sql)。格边框与
628// 分隔线走色板令牌(`splitters::run_split` 只吃 `tokens::shadcn`,V5 钉住);
629// 0.3.0 dock 容器曾接管过这层,0.4.1 拆 dock 后归位。
630// 0.2.30 那条教训仍然成立并记在 DESIGN.md:egui 0.33.3 的 `Frame` 没有 `min_size`,
631// `Frame::paint` 的描边按**内容** min_rect 算(frame.rs:486)——内容少的格边框会跟着缩。
632
633// 表头固定表(BD-UI-34 用户裁定「每个表的表头都要固定」;0.2.29 BD-UI-44 起换成表格库,
634// 0.4.1 BD-UI-55 表格库 = `egui_table 0.7`,用户裁定「表格使用 egui_table」)。
635//
636// 为什么要表格库(实拍毛病 2/3/5,对照 Python 的 QTableView):
637// - **列宽跟内容**:手绘 Grid 的列宽只能取各格 `Response::rect` 的最大值,而
638//   `Response::rect` 是控件矩形不是列框(0.2.28 实锤,egui grid.rs:180-193)
639//   → 列宽只能"差不多",数字列对不齐。表格库的列宽是库自己量出来的(egui_table:
640//   `Column::range` 夹 `max_column_widths`,table.rs:816-843 写回 `ColumnState.width`)。
641// - **表头天生固定**:表头行画在体表滚动区之外(egui_table `Table::show` 先 `header_ui`
642//   再 `SplitScroll`,table.rs:470-537)→ 0.2.8 起那块「ScrollArea 之后重画覆盖带 +
643//   重画表头文字」的 DIY 全撤(当年 DIY 是因为 egui 没有表;scroll_area.rs:412
644//   那句「painting a sticky header on top of it」就是 DIY 的出处)。
645// - **横向溢出**:成交表 14 列比格子宽,Python 出横向滚动条(毛病 5)。egui_table 的
646//   `SplitScroll` 自带横 + 纵两条滚动条:表头跟着体表一起横滚(不错位),竖滚仍归表体
647//   (表头不动)。
648// - **选中 / 悬停底色吃主题**:egui_table 不提供行选中(全库无 `set_selected`)→ 底色由
649//   delegate 的 `row_ui` 自己画,色仍取 `visuals.selection.bg_fill` /
650//   `widgets.hovered.bg_fill`,且按行带全宽画 = 整行底色连续
651//   → 手绘 `paint_row_bg` 那支硬编码半透明蓝撤掉(与 0.2.27「斑马纹跟主题」同口径)。
652// - **网格线(0.4.1 五期 BD-UI-58 起:全部表统一都有)**:列线 = 可拖列边界那根线
653//   (egui_table `Column::resizable` + `chrome().line`),行线 = delegate 每行的行底。
654//   旧口径是「只有合约表开网格」(Python `widget.py:367` 的 `setShowGrid(False)` 只加在
655//   交易表上,合约表 `widget.py:2423`/`2621` 没关,qss `gridline-color:#455364`)——
656//   用户 2026-10-22 实看后裁定撤掉这条分叉:「网格线在黑色背景下比较暗,有横向竖向这些线
657//   反而显得整齐」→ 交易表跟合约表一样都画,`GridLines` 这个开关随之退役。
658//
659// body 闭包在表体内执行(行循环归闭包);行高由本模块统一算,body 只写每格内容。
660// pub(crate):`pinned_table_impl_cols` 是 pub(crate)(日志表字体量宽入口要用)。
661
662/// 行高 / 表头高 = 字号 + 6。Python QTableView 的 sizeHint ≈ 字体高 + 4~6;
663/// 0.2.28 之前 Grid 自然行高在 kittest 实测行间距 21 = 正文 14.7 + 6,同值。
664fn table_heights(ui: &egui::Ui) -> (f32, f32) {
665    let st = ui.style();
666    // 行高必须取「文字高 + 6」与**控件交互高令牌**的较大者。格里有钮 / 勾选框时
667    // 表格层的实际行高 = max(请求值, 格内最高控件):请求值 22 < 实际 26 时,
668    // 行线公式按假行高算 —— 0.2.30 实锤:行线画在 y=514 而第 3 行文字中心在 511
669    // = 0.2.28 治过的毛病 8(行线压到文字中心上)复发。别把这个 max 拆掉。
670    let floor_h = crate::tokens::INTERACT_HEIGHT;
671    (
672        (st.text_styles[&egui::TextStyle::Body].size + 6.0).max(floor_h),
673        (st.text_styles[&egui::TextStyle::Small].size + 6.0).max(floor_h),
674    )
675}
676
677// ================= egui_table 0.7.0 适配层(BD-UI-55,用户裁定「表格使用 egui_table」) =========
678//
679// egui_table 的形制是「拉」:建 `Table`(列/表头/行数)+ `TableDelegate`(表头/行/格的
680// 逐个绘制回调);egui_extras::TableBuilder 的形制是「推」(调用点闭包直接喂行)。
681// 本层把推改成拉:调用点的 body 闭包**原样保留**(行循环 + 每格内容),只是行收进
682// `PinBody`/`PinRow` 的延迟画笔,show 时由 delegate 逐格执行 —— 每张表调用点只多写
683// `selected` 一个参数与 `pin_cell` 一层包装。
684//
685// 每格左内边距 = 6px(egui_table 离 battery 之外不加 margins,old TableBuilder 的
686// item_spacing 缩进由 Frame::inner_margin 补回来,不然文字贴列边框)。
687
688/// 单格延迟画笔(行收集物;腰上 `'b` = pinned_table_impl 的本次调用)。
689/// **FnMut 不是 FnOnce**:egui_table 首帧在同一帧里把每格跑两遍(全量量内容的
690/// invisible sizing pass + 真绘 pass,库自己 `request_discard`)—— FnOnce 在
691/// sizing pass 里就被消费掉,真绘 pass 会全空(0.4.1 实锤:所有格文字消失)。
692/// 格内动作要幂等(select_row/set_order_form 等同帧重放语义不变;egui 的
693/// request_discard 本来就是「整帧重来」)。
694/// 第二个参数 = 这一格的上下文(满格命中层),由 delegate 的 `cell_ui` 造,见 `PinCellCtx`。
695pub(crate) type PinCell<'b> = Box<dyn FnMut(&mut egui::Ui, &PinCellCtx) + 'b>;
696
697/// 一格真实绘制时由 delegate 递给调用点的上下文(BD-UI-55)。
698pub(crate) struct PinCellCtx {
699    /// 满格命中层:在**格那一层**注册的响应(`Ui::interact`,矩形 = 整格)。
700    ///
701    /// 为什么不是 `ui.allocate_rect(ui.max_rect(), ..)`(BD-UI-55 最大拦路虎,已修):
702    /// `allocate_rect` 的口径是「忽略布局,这块地归我」—— 布局 cursor 被推到整格之外,
703    /// 之后格内的内容排到**下一格**去。实锤(临时探针 + vendor 出来的 egui 打点,
704    /// 脚手架已随本次提交拆除,读数留档在交接报告 §4.1 / 交接报告主文 §21.3):
705    /// 格 clip `[901..1001]`,格内 label 的实际矩形 `[1003..1088]` = 错开一整列宽 →
706    /// `Label` 的绘制门 `ui.is_rect_visible(response.rect)` 判假 → 表体文字既不画、
707    /// 也不进语义树。`interact` 不推进布局(ui.rs:1131 只 `create_widget`),
708    /// 满格命中与格内真控件的响应照旧两层并立 = 0.2.29「两边都看」口径不变。
709    ///
710    /// `interact_rect` = 注册处 ui 的 clip ∩ 矩形(ui.rs:1140),所以这颗必须注册在
711    /// **格那一层**(clip = 整格),不能注册在 Frame 内层(clip 已被内边距缩掉 6px)。
712    pub hit: egui::Response,
713}
714
715/// 一行(PinBody 收集物)。行高统一走 delegate 的 `row_h`(`table_heights`),
716/// egui_table 的行是相邻接等高带 —— `body.row(h, ..)` 的 h 现在只是调用点形状兼容,
717/// 不再逐行生效(0.4.1 适配层口径)。
718struct PinRow<'b> {
719    selected: bool,
720    cells: Vec<PinCell<'b>>,
721}
722
723/// 体表 push 收集器(替代 `egui_extras::TableBody` 的角色;BD-UI-55)。
724#[derive(Default)]
725pub(crate) struct PinBody<'b> {
726    rows: Vec<PinRow<'b>>,
727}
728
729impl<'b> PinBody<'b> {
730    /// 与旧 `body.row(h, |mut row| ..)` 同形,多一个 `selected`
731    /// (选中底色从构建时的 `set_selected` 改为收集物,绘制时 `row_ui` 画)。
732    pub fn row(&mut self, _h: f32, selected: bool, add: impl FnOnce(&mut PinRowAdd<'b>)) {
733        let mut r = PinRowAdd {
734            selected,
735            cells: Vec::new(),
736        };
737        add(&mut r);
738        self.rows.push(r.finish());
739    }
740}
741
742/// 行头的 `PinRow::row` 闭包参数,负责收格。
743pub(crate) struct PinRowAdd<'b> {
744    selected: bool,
745    cells: Vec<PinCell<'b>>,
746}
747
748impl<'b> PinRowAdd<'b> {
749    /// 一格,与旧 `row.col(|ui| ..)` 同形(形态不同点:内容延迟到体表真实绘制时跑,
750    /// 且首帧同帧跑两遍 —— FnMut,见 `PinCell`;多出来的第二个参数 = 这一格的
751    /// `PinCellCtx`,由 delegate 的 `cell_ui` 造)。
752    pub fn cell(&mut self, make: impl FnMut(&mut egui::Ui, &PinCellCtx) + 'b) {
753        self.cells.push(Box::new(make));
754    }
755    fn finish(self) -> PinRow<'b> {
756        PinRow {
757            selected: self.selected,
758            cells: self.cells,
759        }
760    }
761}
762
763/// 与旧 `tcell(row, right, make)` 等价的一格:格响应 = 整格可点那一层
764/// (`PinCellCtx::hit`,在格那一层注册的 `interact`),格内控件自己的响应跟随
765/// `CellOutcome::response()`;命中判定(旧宏里 `cell_r.clicked() || inner_r.clicked()`)
766/// 由这两个响应合成:返回 `(cell_r, inner_r)`,闭包内接着写行动作 ——
767/// 与 0.2.29 的「两边都看」完全同口径(egui 一颗点击只发给一个响应,见 `CellOutcome`)。
768pub(crate) fn pin_cell<R: CellOutcome>(
769    ui: &mut egui::Ui,
770    ctx: &PinCellCtx,
771    right_align: bool,
772    make: impl FnOnce(&mut egui::Ui) -> R,
773) -> (egui::Response, Option<egui::Response>) {
774    let inner = if right_align {
775        crate::ui_ext::num_cell(ui, make)
776    } else {
777        make(ui)
778    };
779    (ctx.hit.clone(), inner.response())
780}
781
782/// SplitScroll 的**退化象限**判定(BD-UI-55 实查;`row_ui` 用)。
783///
784/// `num_sticky_cols = 0` 时左上/左下象限的 rect 恒零宽,可 egui_table 无条件调四个
785/// 象限的 delegate(split_scroll.rs:79-151)→ 左下象限里每一行都会调一次 `row_ui`。
786/// 那里画不出任何东西(裁剪矩形零面积 → tessellator.rs:1367 直接丢),但选中底色 /
787/// 行线仍会被登记进本帧形状表 —— 判据读的正是形状表,所以就地不画。
788///
789/// 判据 = 「clip 与格矩形**共边**(宽恰为 0)而格矩形本身正」。这与「格在视口之外」
790/// (clip 与格矩形不相交 → 宽为**负**)是两种不同的退化,后者不能砍:首帧列宽量测靠的
791/// 就是「全列都跑一遍」(table.rs:633-637 的 `do_full_sizing_pass` → `col_range = 0..len`)。
792/// 表头那一侧这两种退化在几何上长得一样,分不开 → `header_cell_ui` 改用调用序判据。
793fn quadrant_is_degenerate(ui: &egui::Ui) -> bool {
794    let c = ui.clip_rect();
795    let m = ui.max_rect();
796    (c.width() == 0.0 && m.width() > 0.0) || (c.height() == 0.0 && m.height() > 0.0)
797}
798
799impl egui_table::TableDelegate for PinDelegate<'_, '_> {
800    fn header_cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::HeaderCellInfo) {
801        // 表头在 SplitScroll 里被画**两遍**:调用序 = right_bottom → left_top → right_top
802        // → left_bottom(split_scroll.rs:79-151)。`num_sticky_cols = 0` → left_top 那一遍
803        // 在零宽象限里:形状画不出(裁剪退化 → tessellator.rs:1367 直接丢),可 egui 的
804        // `Rect::intersects` 用 `<=`(零宽 clip 与文字矩形「相切」也算相交)→
805        // `is_rect_visible` 判真 → 同一颗表头在 AccessKit 语义树里两颗(0.4.1 实锤:
806        // s3/s26/s63/s71 的「成交金额 / 成交号 / 信息 / 最小变动」各两颗)。
807        //
808        // 判据用**调用序**而不是几何:退化象限里 clip 与格矩形相交时宽恰为 0、不相交时
809        // 为负,而「格在视口之外」的 clip 也是负 —— 几何上分不开(本轮实撞:按几何判
810        // 要么漏掉 left_top 的第 2 列起,要么把视口外的表头一起砍掉)。left_top 恒在
811        // right_top 之前 → 本 pass 里这一列的第一次调用就是退化象限,不画。
812        //
813        // 第二遍(视口那一层)**照画,视口外的列也画**:首帧列宽量测靠的就是「全列都跑
814        // 一遍」(table.rs:633-637 的 `do_full_sizing_pass` → `col_range = 0..len`);
815        // 漏掉表头,那一列就只量到体表内容(0.4.1 实锤:「成交金额」列 24px 而表头文字
816        // 57px,表头文字压进隔壁列)。视口外的格 `is_rect_visible` 判假 → 不画也不建
817        // 语义节点,所以照画不会带回双节点。
818        let i = cell.col_range.start;
819        let Some(seen) = self.header_drawn.get(i) else {
820            return;
821        };
822        let seen = *seen;
823        if !seen {
824            self.header_drawn[i] = true;
825            return;
826        }
827        // 表头格:文字(0.4.0 前的形制 == strong + small)+ 可点(排序入口)。
828        // 带色底与铺满全宽的带已在 pinned_table_impl 里画好(在表 show 之前 =
829        // z 序在下),这里只画文字 + register 点击。
830        let i = cell.col_range.start;
831        let mark = match self.sort {
832            Some((c, asc)) if c == i => {
833                if asc {
834                    " ▲"
835                } else {
836                    " ▼"
837                }
838            }
839            _ => "",
840        };
841        // 表头文字上下居中(0.4.1 七期 BD-UI-60,用户裁定「表格里的文字包括抬头都要上下居中」)。
842        // 旧口径:库给的表头格 child ui 起笔在格顶 → 实测带高 29 时文字视觉盒上隙 11 / 下隙 4。
843        // 光靠格内起笔调不出来(galley 的**视觉盒**比它的行盒偏下几 px)→ 直接把这一颗文字
844        // 按带的垂直中心摆:在 `band_mid ∓ 行高/2` 处开一个与格同宽的子 ui。
845        let cell = ui.max_rect();
846        let gh = ui.text_style_height(&egui::TextStyle::Small);
847        let target = egui::Rect::from_min_size(
848            egui::pos2(cell.min.x, self.band_mid - gh * 0.5),
849            egui::vec2(cell.width(), gh),
850        );
851        ui.scope_builder(egui::UiBuilder::new().max_rect(target), |ui| {
852            ui.label(
853                egui::RichText::new(format!("{}{mark}", self.headers[i]))
854                    .strong()
855                    .small(),
856            );
857        });
858        // 点表头 = 排序。id 用「这颗表头格自己的 ui id」:egui_table 给表头格的 child ui
859        // 带 `id_salt(("header", row_nr, group_index))`(table.rs:586-593)→ 列间唯一、
860        // 帧间稳定;旧口径(`ui.id().with(("ek_table_header", table_id, i))`)的等价物。
861        let id = ui.id().with(("hdr_click", i));
862        if ui
863            .interact(ui.max_rect(), id, egui::Sense::click())
864            .clicked()
865        {
866            self.clicked.get_or_insert(i);
867        }
868    }
869
870    fn row_ui(&mut self, ui: &mut egui::Ui, row_nr: u64) {
871        // 左下象限(零宽,`num_sticky_cols = 0`)也会逐行调到这里 —— 与表头同理,
872        // 别让它把选中底色 / 行线登记进形状表。
873        if quadrant_is_degenerate(ui) {
874            return;
875        }
876        let r = ui.max_rect();
877        // **整行**底色,不是「有内容的那几格」(0.4.1 九期 BD-UI-62,用户裁定「我们选的是整行,
878        // 包括空行的也一起给它标上颜色」)。库给的行矩形 = `col_x[0]..=col_x[last]`
879        // (table.rs:689-690) = 所有列的宽度并集;列总宽窄于可视区时(日志表两列、接管版
880        // 列宽的表),行矩形右边就比可视区短一截 → 高亮只到最后一列右边,右边空余不亮。
881        // 补法:右边至少铺到「与表头带同宽」(带 = 可视区宽,`band_x` 构造时已捕获)。
882        let band_w = self.band_x.max - self.band_x.min;
883        let fill = egui::Rect::from_min_max(
884            egui::pos2(r.min.x, r.min.y),
885            egui::pos2(r.max.x.max(r.min.x + band_w), r.max.y),
886        );
887        // 选中底色(0.2.29 口径 = 主题 `selection.bg_fill`,不再手绘蓝)。
888        if let Some(row) = self.rows.get(row_nr as usize) {
889            if row.selected {
890                ui.painter()
891                    .rect_filled(fill, 0.0, ui.visuals().selection.bg_fill);
892            } else if ui.rect_contains_pointer(fill) {
893                // 悬停判定也用 `fill`(整行)而不是 `r`(内容宽):否则「行尾空余」这一带
894                // 鼠标放进去根本不会亮 —— 那等于只把颜色补宽了,裁定没做全。
895                ui.painter()
896                    .rect_filled(fill, 0.0, ui.visuals().widgets.hovered.bg_fill);
897            }
898            // 行线(0.4.1 五期 BD-UI-58:全部表都有,不再是合约表专属)。
899            // x 横跨**表体可视宽**而不是这一行的内容宽:旧口径取 TableBuilder body 的
900            // `max_rect().x_range()` = 横向 ScrollArea 内层 ui 的可用宽 = 整张表宽;
901            // 按单行内容宽画,列内容窄于面板时行线只到最后一列右边,与表头带不同宽
902            // (0.4.1 实锤:合约表行线 427..825,带 423..991 → 判据里 13 行数成 1 根)。
903            // 行线(0.4.1 五期 BD-UI-58:全部表都有;六期 BD-UI-60 起它是**唯一**的网格线,
904            // 列线按用户裁定撤除)。颜色取 delegate 构造时捕获的 `line` —— 表 scope 里
905            // `noninteractive.bg_stroke` 已被改成透明(见 `pinned_table_impl`),在这里现取
906            // 会连行线一起抹掉。
907            ui.painter()
908                .hline(self.band_x, r.max.y, egui::Stroke::new(1.0_f32, self.line));
909        }
910    }
911
912    fn cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::CellInfo) {
913        // 每格左内边距 6(注释见模块头);按列取出延迟画笔就地执行(FnMut,
914        // 首帧两遍都跑;不 replace —— FnOnce 那套会让第二遍全空,0.4.1 实锤)。
915        let Some(row) = self.rows.get_mut(cell.row_nr as usize) else {
916            return;
917        };
918        let Some(slot) = row.cells.get_mut(cell.col_nr) else {
919            // 空态行只推了首格(§4.2 低优):缺格静默跳过。
920            return;
921        };
922        // 满格命中层(见 `PinCellCtx::hit`):注册在格这一层 —— 这层的 clip 就是整格,
923        // `interact` 的 interact_rect = clip ∩ 整格 = 整格。id 用这层自己的 ui.id():
924        // egui_table 给格 child ui 带 `id_salt((row_nr, col_nr))`(table.rs:713-720)
925        // → 格间唯一、帧间稳定(旧 `TableRow::col` 那颗格响应的等价物)。
926        let hit = ui.interact(
927            ui.max_rect(),
928            ui.id().with("pin_cell"),
929            egui::Sense::click(),
930        );
931        let ctx = PinCellCtx { hit };
932        egui::Frame::new()
933            .inner_margin(egui::Margin {
934                left: 6,
935                right: 6,
936                top: 0,
937                bottom: 0,
938            })
939            .show(ui, |ui| slot(ui, &ctx));
940    }
941
942    fn default_row_height(&self) -> f32 {
943        self.row_h
944    }
945}
946
947/// pinned_table 用的 delegate(见模块头说明;字段随 `pinned_table_impl` 收集)。
948struct PinDelegate<'d, 'b> {
949    headers: &'d [&'d str],
950    sort: Option<(usize, bool)>,
951    rows: &'d mut [PinRow<'b>],
952    row_h: f32,
953    clicked: Option<usize>,
954    /// 本 pass 里每列表头已画过没有(表头在 SplitScroll 里被画两遍,见 `header_cell_ui`)。
955    header_drawn: Vec<bool>,
956    /// 表头带的 x 范围 = 表体可视宽。行线按它画,不按单行的内容宽(旧口径,见 `row_ui`)。
957    line: egui::Color32,
958    band_x: egui::Rangef,
959    /// 表头带的垂直中心(0.4.1 七期 BD-UI-60:表头文字按它上下居中,见 `header_cell_ui`)。
960    band_mid: f32,
961}
962
963/// 行高/表头高 = 字号 + 6(`table_heights` 见上;两值直接进 `HeaderRow`/行收集)。
964///
965/// 四个入口都要带自由 `'p`:行收集物 `PinBody<'p>` 的行延迟画笔寿命 = **调用方帧**
966/// (add_body 闭包里的格闭包借用外层局部 —— `'p` 由调用点挑一个盖住它们的生命周期)。
967/// 写成匿名的 `PinBody<'_>`(HRTB ⇒ for<'x> 任一寿命)会让借用逃逸出函数体
968/// (E0521/E0597,本轮实撞)。
969fn pinned_table<'p>(
970    ui: &mut egui::Ui,
971    table_id: &str,
972    headers: &[&str],
973    body: impl FnOnce(&mut PinBody<'p>, f32),
974) -> Option<usize> {
975    pinned_table_impl(ui, table_id, headers, None, body)
976}
977
978/// 0.3.1(Wave D,BD-UI-49)可排序表:`sort` = 当前排序键,返回**这一拍被点过的表头列**。
979/// 排序键由调用方从 `UiShared::table_sort` 取、点完表头再写回 `toggle_table_sort`.
980fn pinned_table_sorted<'p>(
981    ui: &mut egui::Ui,
982    table_id: &str,
983    headers: &[&str],
984    sort: Option<(usize, bool)>,
985    body: impl FnOnce(&mut PinBody<'p>, f32),
986) -> Option<usize> {
987    pinned_table_impl(ui, table_id, headers, sort, body)
988}
989
990/// pinned_table 的共用实现(0.4.1 BD-UI-55 起换 egui_table 0.7.0):
991/// - **表头天生粘顶**(egui_table `headers` 半 sticky row,0.2.6 DIY 覆盖带撤除)。
992/// - **横向溢出**:SplitScroll 的右下象限 = 横向滚动区,表头(右象限)与体表同滚不错位,
993///   表头带照画全宽(带底画在 show 之前 = z 序最底)。
994/// - **列宽**:库的首帧全量量测(do_full_sizing_pass)+ 之后冻结可持续拖/resizable 列。
995///   没有「每帧 auto_size_this_frame」:那会让 delegate(连同我们的action闭包)每帧跑两遍
996///   —— egui 的 sizing pass = request_discard 重跑一遍帧,行动作一切两半(0.4.1 实查:
997///   table.rs:384-443 的 discard 挂 per-column,content 变化重测路径库注释自己也标
998///   TODO)。内容超宽的列(日志正文)由调用方传列宽。
999/// - **行交互**:egui_table 不给格/行级 Response —— 每格用 `pin_cell` 自己建「满格
1000///   allocate + 控件响应」的两层判定(与 0.2.29 的旧「格响应+控件响应」等价)。
1001/// - **选中/悬停**:delegate 的 `row_ui`(行带全宽;旧 TableBuilder 的 expand 缝补
1002///   不再需要 —— egui_table 行与行零间隙)。
1003/// - **斑马纹依旧没有**(0.2.27 起跟主题 = visuals.striped = false)。
1004fn pinned_table_impl<'p>(
1005    ui: &mut egui::Ui,
1006    table_id: &str,
1007    headers: &[&str],
1008    sort: Option<(usize, bool)>,
1009    add_body: impl FnOnce(&mut PinBody<'p>, f32),
1010) -> Option<usize> {
1011    pinned_table_impl_cols(ui, table_id, headers, sort, None, add_body)
1012}
1013
1014/// 空态列宽(0.4.1 五期 BD-UI-58,用户裁定「按表头分配各列比例,等有数据再按数据调整」):
1015/// 按**表头文字宽的比例**把这一拍的可用宽分掉。表头总宽 > 可用宽时不放大(照内容走横向
1016/// 滚动,与 Python `ResizeToContents` 同形)。`+12` = 格左右内边距 6+6(`cell_ui` 的
1017/// `inner_margin`),下限 24 = `Column::range` 下限。
1018fn header_proportional_widths(ui: &egui::Ui, headers: &[&str], available: f32) -> Vec<f32> {
1019    let head_font = ui.style().text_styles[&egui::TextStyle::Small].clone();
1020    let head_w = |s: &str| -> f32 {
1021        let g = ui.painter().layout_no_wrap(
1022            s.to_string(),
1023            head_font.clone(),
1024            egui::Color32::TRANSPARENT,
1025        );
1026        (g.size().x + 12.0).max(24.0)
1027    };
1028    fill_proportional(headers.iter().map(|h| head_w(h)).collect(), available)
1029}
1030
1031/// 表头份额不足可用宽时按比例放大到刚好填满;够宽(会溢出)时原样返回(走横向滚动)。
1032fn fill_proportional(mut base: Vec<f32>, available: f32) -> Vec<f32> {
1033    let sum: f32 = base.iter().sum();
1034    if sum > 0.0 && sum < available {
1035        let k = available / sum;
1036        for w in base.iter_mut() {
1037            *w *= k;
1038        }
1039    }
1040    base
1041}
1042
1043/// 列宽接管版(BD-UI-55 适配层第二入口):内容**逐帧生长**的表(日志正文)由调用方
1044/// 传列当前宽 —— 字体量串走 egui 的 galley 缓存(同串命中),不走库的
1045/// auto_size_this_frame(那条路 = request_discard 每帧双 pass,action 闭包跑两遍)。
1046/// 空态(这一拍没有数据行,由 `n_rows == 0` 现场判定,不用调用方声明)且没传列宽时,
1047/// 按表头比例分配(见 `header_proportional_widths`)。
1048pub(crate) fn pinned_table_impl_cols<'p>(
1049    ui: &mut egui::Ui,
1050    table_id: &str,
1051    headers: &[&str],
1052    sort: Option<(usize, bool)>,
1053    col_current: Option<Vec<f32>>,
1054    add_body: impl FnOnce(&mut PinBody<'p>, f32),
1055) -> Option<usize> {
1056    let ch = Chrome::of(ui.visuals());
1057    let (row_h, head_h) = table_heights(ui);
1058    let sp = ui.spacing().item_spacing;
1059    let m = ui.visuals().clip_rect_margin;
1060    // 表头带:整行宽(Python QHeaderView 铺满表视口;0.2.28 给浮动窗短表补的
1061    // header_band 铺整行是同一件事)。画在表之前 = 在表头文字之下,零布局副作用
1062    // (0.2.6「资金右多一根竖线」那类 min_rect 被撑宽的病在这里不会再犯)。
1063    let left = ui.cursor().min.x - sp.x * 0.5;
1064    let top = ui.cursor().top();
1065    // 带底 = 表头行底边,egui_table 的表头行高就是 `head_h` 且**体表紧接着开始**
1066    // (行与行、表头与体表都不留 item_spacing,table.rs:404-411 `header_row_y` 累加
1067    // `header.height()`)。旧 TableBuilder 在表头与首行之间留了一格 `sp.y`,带底才多画
1068    // 那 6 px —— 照搬过来带底就压进首行(0.4.1 实锤:带 407..442,首行 436..462,
1069    // 首行文字 440..458 被带底横线从中间切过)。
1070    let band = egui::Rect::from_min_max(
1071        egui::pos2(left, top - m),
1072        egui::pos2(left + ui.available_width() + sp.x, top + head_h),
1073    );
1074    ui.painter().rect_filled(band, 0.0, ch.band_fill);
1075    // 带底下边线:表头带与体表的分界(Python 的表头带与体表是两种底色,天然有界;
1076    // 我们只有一根带,补线才有界)。
1077    ui.painter().hline(
1078        band.x_range(),
1079        band.max.y,
1080        egui::Stroke::new(1.0_f32, ch.line),
1081    );
1082    // 滚动条形态:全局一处口径(`tokens::apply` 的 `spacing.scroll`,0.4.1 七期 BD-UI-60
1083    // 起 5 px 细 + BG4 暗把手)。SplitScroll 内部是标准 ScrollArea,吃同一套 spacing。
1084    let mut body = PinBody::default();
1085    add_body(&mut body, row_h);
1086    let n_rows = body.rows.len() as u64;
1087
1088    // 列(0.4.1 五期 BD-UI-58,用户裁定):`resizable` **恒开**,两个理由绑在一起 ——
1089    // ① 列线:库只在 resizable 列上画那根可拖边界线(table.rs:740-749 登记 + 896 画线),
1090    //    这就是用户要的「竖向线显得整齐」;旧口径只有合约表 + 日志表 + 开了右键开关的
1091    //    成交表有列线,委托/持仓/资金/行情没有 → 形制不统一。
1092    // ② 列宽随内容生长:`finish` 的 grow-only 分支(table.rs:840-843)只对 resizable 列
1093    //    生效,旧口径非 resizable 的表列宽只在**首帧**量一次(首帧空态 → 列宽 = 表头
1094    //    文字宽,数据进来也不长;BD-UI-56 实测:成交表首帧空态列宽 [63.3, 28.4, 43.4,
1095    //    …] 喂进「螺纹钢2605 / 71230.5 / 142461.0」再跑三帧,一模一样)。这条缺陷跟着
1096    //    恒开一起修掉。右键菜单里那个「调整列宽」开关因此失去意义 → 一并退役。
1097    let mut delegate = PinDelegate {
1098        headers,
1099        sort,
1100        rows: &mut body.rows,
1101        row_h,
1102        clicked: None,
1103        header_drawn: vec![false; headers.len()],
1104        band_x: band.x_range(),
1105        line: ch.line,
1106        band_mid: band.center().y,
1107    };
1108    // egui_table 的表 scope **不带 id_salt**:SplitScroll 内部 `ui.new_child()`
1109    // 的 ScrollArea auto-id 由父 ui 的 child 计数器派生 —— 2×2 各格的表(
1110    // order_grid 与 log_grid)实测滚出同一个 id(17412…5594)—— 滚动状态互相
1111    // 污染 → `ScrollArea::show_scroll` 每帧交替翻转 → request_repaint 死循环
1112    // (kittest max_steps 实锤,本轮 `[scrollflip]` 打点)。所以这里显式
1113    // scope 一层,把每张表的整个子 ui 树根挂在以 table_id 命名的盐上,
1114    // 滚动 id 与列宽状态各自独立。
1115    let _ret = ui.scope_builder(
1116        egui::UiBuilder::new()
1117            .id_salt(("pinned_table", table_id))
1118            // scope 的可用区必须从**当前 cursor** 起,不是从 max_rect 左上角:
1119            // `Table::show` 的 col_x / 表头行 y 都取 `ui.cursor().min`(table.rs:393-411),
1120            // 而上面那条表头带是按 cursor 画的 —— 用 `ui.max_rect()` 会让表画到
1121            // cursor **上方**去,带与表头行错开(0.4.1 实锤 s70:资金表头格 y 568..594,
1122            // 带却画在 y 597..632 = 差一整行)。
1123            .max_rect(egui::Rect::from_min_max(ui.cursor().min, ui.max_rect().max)),
1124        |ui| {
1125            // 列宽校准(BD-UI-58 立,BD-UI-60 补全):表宽 = 所在区域的 100%。
1126            // Python 侧交易表是 `resizeSections(ResizeToContents)`(widget.py:646)+ Qt
1127            // `stretchLastSection` 默认 True(全仓只有 portfoliomanager 的树控件显式关掉它)
1128            // → 列按内容量,剩余宽由**最后一列**吃掉,表宽恒等于视口宽;列总宽超过视口时
1129            // Qt 不压缩、出横向滚动条 —— 我们照这个口径,超宽不压。
1130            // 为什么必须**写进 `TableState`**而不是只传 `Column::new(w)`:库只在状态里
1131            // 没有这张表时才读 `column.current`(table.rs:370-375),而那一刻恰好就是首帧
1132            // ——首帧库给每列打上 `auto_size_this_frame`(table.rs:377-379),`finish` 于是
1133            // 走「按内容收缩」那一支(table.rs:837-839)把种子覆盖掉(0.4.1 五期实测:只传
1134            // 种子,空态右侧照样空 663 px)。先写状态,`TableState::load` 就不再是 None。
1135            // 只在「状态里的列宽总和还填不满可用宽」时动手:用户手动拖宽之后不再跟他抢。
1136            let state_id = egui_table::TableState::id(ui, egui::Id::new(table_id));
1137            // 目标宽要扣掉这张表自己的滚动条占位(`floating = false` 时 ScrollArea 会真的
1138            // 留出 bar_width + 内外边距):不扣就会把列总宽做到「比可视区还宽」,最后一列
1139            // 被推到视口外,egui_table 剔掉视口外的列 → 那一列上的撤单钮 / 时间格直接消失
1140            // (0.4.1 七期实撞:s11 撤单钮、s38 已摘除标注、w6 成交时间列同因)。
1141            let sc = &ui.style().spacing.scroll;
1142            let reserve = if sc.floating {
1143                0.0
1144            } else {
1145                sc.bar_width + sc.bar_inner_margin + sc.bar_outer_margin
1146            };
1147            let target = (ui.available_width() - reserve).max(0.0);
1148            // 补宽**一次只补一次**,不每帧补:库的 `finish` 把列宽写回成 `max(可视内容宽)`,
1149            // 被剔出视口的列写回的是它这一帧的量测宽 → 每帧补就「补宽 → 更挤 → 更多列被剔 →
1150            // 存宽更小 → 再补」滚起来(0.4.1 七期实撞:每帧补时 s11/s21/t3/f2/w6 同时丢格)。
1151            // 判据 = 「区域宽变了」或「存宽变了」(= 数据变了)才重新允许补一次;最后一列最多
1152            // 吃掉半张表,补不出无限宽的列。
1153            let fill_id = egui::Id::new(("table_fill", table_id));
1154            let (last_target, last_stored, filled) = ui.ctx().data(|d| {
1155                (
1156                    d.get_temp::<f32>(fill_id).unwrap_or(0.0),
1157                    d.get_temp::<f32>(fill_id.with("stored")).unwrap_or(0.0),
1158                    d.get_temp::<bool>(fill_id.with("filled")).unwrap_or(false),
1159                )
1160            });
1161            let mut st = egui_table::TableState::load(ui.ctx(), state_id).unwrap_or_default();
1162            let stored: f32 = st.col_widths.values().sum();
1163            let may_fill = !filled
1164                && ((target - last_target).abs() > 8.0 || (stored - last_stored).abs() > 0.5);
1165            let mut did_fill = false;
1166            let mut new_stored = stored;
1167            if col_current.is_none() && may_fill && stored < target {
1168                if stored <= 0.0 {
1169                    // 这一张表还没量过列宽。**只有空态**才在这里按表头比例下种子(BD-UI-58
1170                    // 口径);有数据时不写,让库在首帧做全量内容量测(`do_full_sizing_pass`)
1171                    // —— 提前下种子会把「时间」「接口」这类窄表头列压成 26 px,格内文字被
1172                    // 列宽裁掉(0.4.1 七期实撞:w6 成交时间列、s38 已摘除标注)。
1173                    if n_rows == 0 {
1174                        for (i, w) in header_proportional_widths(ui, headers, target)
1175                            .into_iter()
1176                            .enumerate()
1177                        {
1178                            st.col_widths.insert(egui::Id::new(i), w);
1179                        }
1180                        did_fill = true;
1181                    }
1182                } else {
1183                    // 已有列宽(按内容量过):剩余补给最后一列 = stretchLastSection 等价。
1184                    let last = egui::Id::new(headers.len() - 1);
1185                    let cur = st.col_widths.get(&last).copied().unwrap_or(24.0);
1186                    let add = (target - stored).min((target * 0.5 - cur).max(0.0));
1187                    if add > 0.5 {
1188                        st.col_widths.insert(last, cur + add);
1189                        did_fill = true;
1190                    }
1191                }
1192                if did_fill {
1193                    new_stored = st.col_widths.values().sum::<f32>();
1194                    st.store(ui.ctx(), state_id);
1195                }
1196            }
1197            ui.ctx().data_mut(|d| {
1198                d.insert_temp(fill_id, target);
1199                d.insert_temp(fill_id.with("stored"), new_stored);
1200                d.insert_temp(fill_id.with("filled"), did_fill || filled);
1201            });
1202            let seed: Option<Vec<f32>> = col_current;
1203            // 列线(0.4.1 七期 BD-UI-60,用户裁定「表格里所有的列分割线都去掉,只要行的分割线」):
1204            // 库里那根线撤不掉 —— 它只在**可拖列边界**上画(table.rs:738-749 登记 + 896 画线),
1205            // 而 `resizable` 必须恒开(列宽随内容生长靠它,BD-UI-56 那条缺陷就堵在这)。
1206            // 做法 = 把它的**常态**描边改成全透明 → 平时看不见;hover / 拖拽仍走
1207            // `widgets.hovered` / `widgets.active` 的描边 → 拖列宽时那根线亮出来(用户裁定保留)。
1208            // 只改这一支,不改 `BORDER` 本体:行线、表头带底边线、窗框都另有取色(行线取
1209            // delegate 里捕获的 `line`)。
1210            ui.style_mut().visuals.widgets.noninteractive.bg_stroke =
1211                egui::Stroke::new(1.0_f32, egui::Color32::TRANSPARENT);
1212            // 列宽从哪来(0.4.1 实查,库侧两条硬事实):
1213            // ① `Table::show` 只在**首帧**(`TableState::load` 为 None)做全量内容量测
1214            //    (`do_full_sizing_pass`,table.rs:365-379);
1215            // ② 量出来的宽度只有 **resizable 列**才会写回 `TableState` —— `finish`
1216            //    (table.rs:816-843)只遍历 `visible_column_lines`,而这两根线只在
1217            //    `column.resizable` 时登记(table.rs:614、table.rs:738-749)。
1218            // 0.4.1 五期(BD-UI-58)起 `resizable` 恒开:①②两条从此**每张表都吃到**
1219            // (列宽随内容生长 + 每列一根可拖列线),不再需要「首帧临时全标 resizable」
1220            // 那套补丁。起手宽 = 空态种子(按表头比例)或 100(库随后按内容量测覆盖)。
1221            let columns: Vec<egui_table::Column> = match &seed {
1222                Some(ws) => headers
1223                    .iter()
1224                    .zip(ws.iter().copied())
1225                    .enumerate()
1226                    .map(|(i, (_, w))| {
1227                        egui_table::Column::new(w)
1228                            .range(24.0..=f32::INFINITY)
1229                            .id(egui::Id::new(i))
1230                            .resizable(true)
1231                    })
1232                    .collect(),
1233                None => headers
1234                    .iter()
1235                    .enumerate()
1236                    .map(|(i, _)| {
1237                        egui_table::Column::new(100.0)
1238                            .range(24.0..=f32::INFINITY)
1239                            .id(egui::Id::new(i))
1240                            .resizable(true)
1241                    })
1242                    .collect(),
1243            };
1244            egui_table::Table::new()
1245                .id_salt(table_id)
1246                .num_rows(n_rows)
1247                .columns(columns)
1248                .headers([egui_table::HeaderRow::new(head_h)])
1249                .auto_size_mode(egui_table::AutoSizeMode::Never)
1250                .show(ui, &mut delegate);
1251        },
1252    );
1253    delegate.clicked
1254}
1255/// 为什么要这个:egui 的点击只发给**一个**响应 —— `context.rs:1384`
1256/// `let clicked = Some(id) == viewport.interact_widgets.clicked;` 比的是单个 Id,
1257/// 后加的控件优先。格里有真控件(钮 / 勾选框)时,点击归那个控件,归不到
1258/// `TableRow::col` 返回的「格响应」。所以行动作必须两边都看(0.2.29 实锤:
1259/// 只看格响应 → 行情首列钮、委托撤单钮、合约勾选框三处点击全丢)。
1260/// `pin_cell` 是 pub(crate)(kittest 判据要用),所以本 trait 也要 pub(crate)
1261/// (否则 clippy private-trait 警报)。
1262pub(crate) trait CellOutcome {
1263    fn response(&self) -> Option<egui::Response>;
1264}
1265
1266impl CellOutcome for egui::Response {
1267    fn response(&self) -> Option<egui::Response> {
1268        Some(self.clone())
1269    }
1270}
1271
1272impl CellOutcome for () {
1273    fn response(&self) -> Option<egui::Response> {
1274        None
1275    }
1276}
1277
1278impl<T: CellOutcome> CellOutcome for egui::InnerResponse<T> {
1279    fn response(&self) -> Option<egui::Response> {
1280        self.inner
1281            .response()
1282            .or_else(|| Some(self.response.clone()))
1283    }
1284}
1285
1286/// 喂表前的快照副本排序(W2 的判据对象)。`key` 返回**可比较字符串**;数字列调用方给
1287/// 零填充串(`{:020.4}`)保证字典序 == 数值序。`sort` 为 `None` = 原序,一行都不动。
1288fn sorted_rows<'a, T>(
1289    rows: &'a [T],
1290    sort: Option<(usize, bool)>,
1291    key: impl Fn(&T, usize) -> String,
1292) -> Vec<&'a T> {
1293    let mut out: Vec<&'a T> = rows.iter().collect();
1294    if let Some((col, asc)) = sort {
1295        out.sort_by(|a, b| {
1296            let (ka, kb) = (key(a, col), key(b, col));
1297            if asc { ka.cmp(&kb) } else { kb.cmp(&ka) }
1298        });
1299    }
1300    out
1301}
1302
1303/// 成交行的展示字符串(`TRADE_HEADERS` 同序)。**排序键与「保存数据」CSV 共用这一份口径** ——
1304/// 表里看见什么,导出来就是什么,不留第二套格式化。
1305/// 0.3.3 Wave F:这一份的顺序就是 `TRADE_HEADERS` 的顺序(判据 F1 钉「内容与表头同列」,
1306/// 两处不同序 = CSV 与屏幕不一致)。
1307fn trade_cells(t: &ek_data::trade::TradeData) -> Vec<String> {
1308    vec![
1309        t.symbol.clone(),
1310        if t.name.is_empty() {
1311            "-".to_string()
1312        } else {
1313            t.name.clone()
1314        },
1315        t.exchange.as_str().to_string(),
1316        t.direction
1317            .map(|d| d.as_str().to_string())
1318            .unwrap_or_else(|| "-".to_string()),
1319        t.offset.as_str().to_string(),
1320        crate::format::price(t.price),
1321        crate::format::volume(t.volume),
1322        crate::format::price(t.traded_amount),
1323        crate::format::time_hms_millis(t.datetime),
1324        t.tradeid.clone(),
1325        t.orderid.clone(),
1326        t.gateway_name.clone(),
1327    ]
1328}
1329
1330/// 排序键:数字列给**零填充串**(`{:020.4}`)保证字典序 == 数值序;文本列直接比。
1331/// 注意不能直接拿 `trade_cells` 的显示串当排序键 —— "101" < "12.345" < "3.5" 是字典序,
1332/// 不是价格序(本轮实撞,判据 W1 当场红出来的)。
1333/// 列序 0.3.3 Wave F 跟着 `TRADE_HEADERS` 走:5 = 价格,6 = 数量,7 = 成交金额,8 = 时间。
1334fn trade_sort_key(t: &ek_data::trade::TradeData, col: usize) -> String {
1335    match col {
1336        5 => format!("{:020.4}", t.price),
1337        6 => format!("{:020.4}", t.volume),
1338        7 => format!("{:020.4}", t.traded_amount),
1339        8 => t.datetime.map(|ns| format!("{ns:025}")).unwrap_or_default(),
1340        c => trade_cells(t).get(c).cloned().unwrap_or_default(),
1341    }
1342}
1343
1344/// 「保存数据」:当前表(已按界面排序键排好的那份副本)写成 CSV 到 `target/ek-ui-<表>.csv`。
1345/// 只写界面看见的内容,不碰后端、不碰数据模型。
1346fn save_table_csv<T>(
1347    table: &str,
1348    headers: &[&str],
1349    rows: &[&T],
1350    cells: impl Fn(&T) -> Vec<String>,
1351) -> std::io::Result<()> {
1352    let mut s = headers.join(",");
1353    s.push('\n');
1354    for r in rows {
1355        s.push_str(&cells(r).join(","));
1356        s.push('\n');
1357    }
1358    let dir = std::env::current_dir()?.join("target");
1359    std::fs::create_dir_all(&dir)?;
1360    std::fs::write(dir.join(format!("ek-ui-{table}.csv")), s)
1361}
1362
1363// ================= 菜单栏 =================
1364
1365fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
1366    ui.horizontal(|ui| {
1367        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
1368        if ui.button(MENU_OPS).clicked() {
1369            shared.set_ops_window_open(true);
1370        }
1371        if ui.button(MENU_ACCOUNTS).clicked() {
1372            shared.set_accounts_window_open(true);
1373        }
1374        if ui.button(MENU_CONTRACTS).clicked() {
1375            shared.set_contracts_window_open(true);
1376        }
1377        // 0.3.0(Wave C,BD-UI-48)→ 0.4.1(BD-UI-54):布局可复位 —— 一键回
1378        // Python 框架等价(分刀份额 50:50 + 三个 tab 态回缺省;行情带/下单侧两块
1379        // 顶层面板的记忆尺寸一并清掉 = 下帧回 default_height/default_width)。
1380        if ui.button("恢复布局").clicked() {
1381            shared.reset_layout();
1382            ui.ctx().data_mut(|d| {
1383                d.remove::<egui::containers::panel::PanelState>(egui::Id::new("market_band"));
1384                d.remove::<egui::containers::panel::PanelState>(egui::Id::new("order_side"));
1385            });
1386        }
1387        ui.menu_button(MENU_HELP, |ui| {
1388            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
1389            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
1390        });
1391        // 0.2.30(裁定 8,BD-UI-46):主题一键循环钮删除。界面只有一套 Qt深色
1392        // (qdarkstyle 3.2.3 复刻),「可换主题」这件事本身从产品面上撤掉了。
1393    });
1394}
1395
1396// ================= 行情格 =================
1397
1398/// 行情表头(14 列,与 `tick_sort_key` 同序)。
1399const TICK_HEADERS: [&str; 14] = [
1400    "代码",
1401    "名称",
1402    "最新价",
1403    "涨跌幅",
1404    "成交量",
1405    "今开",
1406    "最高",
1407    "最低",
1408    "买1价",
1409    "买1量",
1410    "卖1价",
1411    "卖1量",
1412    "时间",
1413    "行情源",
1414];
1415
1416/// 行情格:一个交易所一格(0.4.0)。组内那条「按所二级 tab」条已撤除 —— 所的身份由
1417/// dock tab 承担(该格只画本所的行;哪一所在前台由 dock 树记)。
1418pub(crate) fn render_market_pane(
1419    ui: &mut egui::Ui,
1420    shared: &UiShared,
1421    ex: ek_data::constant::Exchange,
1422) {
1423    // 表状态按格分开:6 个所的行情格各有各的排序与列宽(同一个 id 会被 egui 当重复)。
1424    let table_id = format!("tick_{}", ex.as_str());
1425    let groups = shared.tick_groups();
1426    let ticks: Vec<ek_data::tick::TickData> = groups
1427        .iter()
1428        .find(|(e, _)| e == ex.as_str())
1429        .map(|(_, v)| v.clone())
1430        .unwrap_or_default();
1431    // 空态口径(0.4.1 五期 BD-UI-58 + 六期 BD-UI-59 两次裁定合起来):表头恒在(s73 钉),但**不画那一行
1432    // 「(无数据)」提示** —— 表体就是空的,用户看得见;列宽这一拍按表头比例填满(s74 钉)。
1433    // 「这一拍没有数据行」由 `pinned_table_impl_cols` 现场数 `n_rows == 0` 判定,
1434    // 调用方不再分支:空与非空走同一条路,body 收出 0 行就是空表。
1435    // (0.4.0 那条「空态不得吞掉表头」的修正仍然成立:这里原来直接 `muted_label + return`,
1436    // 整张表含表头都不画,六个行情格在没有订阅时全是光秃秃一句提示。Python 也是空表照画表头。)
1437    // 0.3.3(收 Wave D 欠账):行情格也吃「点表头排序」(缺省序 = 按所/symbol)。
1438    let sort = shared.table_sort(&table_id);
1439    let ticks = sorted_rows(&ticks, sort, tick_sort_key);
1440    let clicked = pinned_table_sorted(ui, &table_id, &TICK_HEADERS, sort, |body, row_h| {
1441        const RIGHT_COLS: [usize; 10] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
1442        for t in &ticks {
1443            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
1444            // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
1445            // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
1446            // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
1447            let row_sel = shared.order_form().symbol == t.symbol;
1448            body.row(row_h, row_sel, |r| {
1449                // 延迟画笔(BD-UI-55):格内容在体表真实绘制时跑;点击/双击与
1450                // 行动作的判定照旧在「格响应 || 控件响应」两边看,BD-UI-55 实查:
1451                // egui_table 每帧只跑一遍(不用 auto_size_this_frame,那会双跑)。
1452                // 闭包不 move(借用 shared / t / key —— 它们都是外层的只读局部,
1453                // 出不了 pinned_table_impl 的调用帧,借给 'b 完全合法)。
1454                macro_rules! cell {
1455                    ($make:expr, $col:expr) => {{
1456                        r.cell(|ui, ctx| {
1457                            let (cell_r, inner_r) =
1458                                pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
1459                            let hit =
1460                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
1461                            if hit {
1462                                // import_tick(用户裁定:0.4.1 行单击不再切交易所 ——
1463                                // 本格已是该所;这里只把合约带进下单面板)。
1464                                import_tick(shared, t, ex.as_str(), $col);
1465                            }
1466                            let dbl = cell_r.double_clicked()
1467                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
1468                            if dbl {
1469                                shared.open_kline(t.symbol.clone());
1470                            }
1471                        });
1472                    }};
1473                }
1474                cell!(|ui| ui.button(t.symbol.clone()), 0);
1475                cell!(
1476                    |ui| ui.label(if t.name.is_empty() {
1477                        "-".to_string()
1478                    } else {
1479                        t.name.clone()
1480                    }),
1481                    1
1482                );
1483                cell!(|ui| ui.label(crate::format::price(t.last_price)), 2);
1484                cell!(
1485                    |ui| ui.label(
1486                        egui::RichText::new(crate::format::pct(t.pct_change))
1487                            .color(crate::tokens::rise_fall(t.pct_change)),
1488                    ),
1489                    3
1490                );
1491                cell!(|ui| ui.label(crate::format::volume(t.volume)), 4);
1492                cell!(|ui| ui.label(crate::format::price(t.open_price)), 5);
1493                cell!(|ui| ui.label(crate::format::price(t.high_price)), 6);
1494                cell!(|ui| ui.label(crate::format::price(t.low_price)), 7);
1495                cell!(
1496                    |ui| ui.label(
1497                        egui::RichText::new(crate::format::price(t.bid_price_1))
1498                            .color(crate::tokens::semantic::RISE),
1499                    ),
1500                    8
1501                );
1502                cell!(
1503                    |ui| ui.label(
1504                        egui::RichText::new(crate::format::volume(t.bid_volume_1))
1505                            .color(crate::tokens::semantic::RISE),
1506                    ),
1507                    9
1508                );
1509                cell!(
1510                    |ui| ui.label(
1511                        egui::RichText::new(crate::format::price(t.ask_price_1))
1512                            .color(crate::tokens::semantic::FALL),
1513                    ),
1514                    10
1515                );
1516                cell!(
1517                    |ui| ui.label(
1518                        egui::RichText::new(crate::format::volume(t.ask_volume_1))
1519                            .color(crate::tokens::semantic::FALL),
1520                    ),
1521                    11
1522                );
1523                cell!(
1524                    |ui| ui.label(if t.update_time.is_empty() {
1525                        crate::format::time_hms_millis(Some(t.datetime))
1526                    } else {
1527                        t.update_time.clone()
1528                    }),
1529                    12
1530                );
1531                cell!(|ui| ui.label(t.gateway_name.clone()), 13);
1532            });
1533        }
1534    });
1535    if let Some(col) = clicked {
1536        shared.toggle_table_sort(&table_id, col);
1537    }
1538}
1539
1540/// 行情行的排序键(列序 == 行情带 14 列):数字列零填充,时间列纳秒零填充。
1541fn tick_sort_key(t: &ek_data::tick::TickData, col: usize) -> String {
1542    match col {
1543        0 => t.symbol.clone(),
1544        1 => t.name.clone(),
1545        2 => format!("{:020.4}", t.last_price),
1546        3 => format!("{:020.4}", t.pct_change),
1547        4 => format!("{:020.4}", t.volume),
1548        5 => format!("{:020.4}", t.open_price),
1549        6 => format!("{:020.4}", t.high_price),
1550        7 => format!("{:020.4}", t.low_price),
1551        8 => format!("{:020.4}", t.bid_price_1),
1552        9 => format!("{:020.4}", t.bid_volume_1),
1553        10 => format!("{:020.4}", t.ask_price_1),
1554        11 => format!("{:020.4}", t.ask_volume_1),
1555        12 => {
1556            if t.update_time.is_empty() {
1557                format!("{:025}", t.datetime)
1558            } else {
1559                t.update_time.clone()
1560            }
1561        }
1562        13 => t.gateway_name.clone(),
1563        _ => String::new(),
1564    }
1565}
1566
1567// ================= 下单面板(常驻;0.4.1 BD-UI-54 回原生 SidePanel) =================
1568
1569fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
1570    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
1571    let mut f = shared.order_form();
1572    let before = f.clone();
1573    let mut err: Option<String> = None;
1574
1575    // 随价勾选:价格框随该合约最新 tick(勾选态)
1576    if f.follow_price
1577        && let Some(t) = tick_of(shared, &f.exchange, &f.symbol)
1578        && t.last_price > 0.0
1579    {
1580        f.price = crate::format::price(t.last_price);
1581    }
1582
1583    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
1584    let mut ex_opts: Vec<String> = shared
1585        .tick_groups()
1586        .iter()
1587        .map(|(e, _)| e.clone())
1588        .collect();
1589    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
1590        if !ex_opts.iter().any(|x| x == e) {
1591            ex_opts.push(e.to_string());
1592        }
1593    }
1594    if !ex_opts.contains(&f.exchange) {
1595        ex_opts.insert(0, f.exchange.clone());
1596    }
1597    // 接口下拉:账户清册(注册序)∪ trade_snap 网关去重集(B3 退役:
1598    // 不再硬编码 `["ctp"]`;0.2.10 BD-UI-38 sim 纸面网关退场同口径)。
1599    let ts = shared.trade_snap();
1600    let mut gw_opts: Vec<String> = Vec::new();
1601    // 0.2.18(规划 Q4):下单归属只列**已装配**账户 —— 只添加了还没连接的账户
1602    // 进了下拉,选它下单只会撞 `unknown:gateway`。
1603    // 账本侧历史网关名的口径(执行核查补的洞):清册非空时,**不在清册的历史网关
1604    // = 已摘除账户**,它留在下拉里同样是选它下单撞 `unknown:gateway` → 不进下拉
1605    // (撤单不受影响:撤单归属按每笔单自己的网关走,与这里的下拉无关)。
1606    // 清册为空 = 不干预(纯账本巡回钉 s27/s14 那类无账户配置面的形)。
1607    let known_gws = account_gateways(shared);
1608    for g in shared
1609        .accounts()
1610        .iter()
1611        .filter(|a| a.connected)
1612        .map(|a| a.gateway.clone())
1613        .chain(
1614            ts.orders
1615                .iter()
1616                .map(|o| o.gateway_name.clone())
1617                .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
1618                .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
1619                .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
1620        )
1621    {
1622        if !g.is_empty() && !gw_opts.contains(&g) {
1623            gw_opts.push(g);
1624        }
1625    }
1626    if !gw_opts.contains(&f.gateway)
1627        && !f.gateway.is_empty()
1628        && (known_gws.is_empty() || known_gws.contains(&f.gateway))
1629    {
1630        gw_opts.insert(0, f.gateway.clone());
1631    }
1632    // 手动选过的网关若已不在可选面(被摘除 / 未装配)= 就地回落到默认阶梯:
1633    // 否则下拉里挂着一个注定 `unknown:gateway` 的选项,且它会成为下一笔单的归属。
1634    if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
1635        f.gateway = shared.default_gateway().unwrap_or_default();
1636    }
1637    // 表单网关位为空 = 未手动选过:回填默认账户阶梯(上次选择 → 首个已连接 → 首个)。
1638    // 只改本地 `f` 副本 —— 面板末尾的 `f != before` 写回负责落盘,不在这里重复写。
1639    if f.gateway.is_empty()
1640        && let Some(def) = shared.default_gateway()
1641    {
1642        f.gateway = def;
1643    }
1644    let no_accounts = gw_opts.is_empty();
1645
1646    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
1647    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
1648    egui::Grid::new("order_form_grid")
1649        .num_columns(2)
1650        .show(ui, |ui| {
1651            ui.label("交易所");
1652            egui::ComboBox::from_id_salt("of_exchange")
1653                .selected_text(f.exchange.clone())
1654                .show_ui(ui, |ui| {
1655                    for e in &ex_opts {
1656                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
1657                    }
1658                });
1659            ui.end_row();
1660            ui.label("代码");
1661            let sym_resp = ui.add(
1662                egui::TextEdit::singleline(&mut f.symbol)
1663                    .hint_text("cu2709")
1664                    .desired_width(150.0),
1665            );
1666            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
1667            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
1668            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
1669            // has_focus 口径回车订阅从不触发)。
1670            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
1671                let s = f.symbol.trim().to_string();
1672                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
1673                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
1674                let bare = match s.split_once('.') {
1675                    Some((_, b)) => b.trim().to_string(),
1676                    None => s,
1677                };
1678                if !bare.is_empty() {
1679                    f.symbol = bare.clone();
1680                    // 订阅一律路由到行情元账号(规划 §5.C:单源;从账号无 MD 会话,
1681                    // 发给它只会静默成功 = 观测黑洞,所以路由在界面侧定死)。
1682                    match subscribe_target(shared) {
1683                        Ok(gw) => {
1684                            shared.push_cmd(UiCmd::CtpSubscribe {
1685                                gateway: gw.clone(),
1686                                symbols: vec![bare.clone()],
1687                            });
1688                            shared
1689                                .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
1690                        }
1691                        Err(e) => shared.log_line(format!("[订阅] {e}")),
1692                    }
1693                }
1694            }
1695            ui.end_row();
1696            ui.label("名称");
1697            ui.label(
1698                tick_of(shared, &f.exchange, &f.symbol)
1699                    .map(|t| {
1700                        if t.name.is_empty() {
1701                            "-".to_string()
1702                        } else {
1703                            t.name
1704                        }
1705                    })
1706                    .unwrap_or_else(|| "-".to_string()),
1707            );
1708            ui.end_row();
1709            ui.label("方向");
1710            egui::ComboBox::from_id_salt("of_direction")
1711                .selected_text(f.direction.as_str())
1712                .show_ui(ui, |ui| {
1713                    for d in [Direction::LONG, Direction::SHORT] {
1714                        ui.selectable_value(&mut f.direction, d, d.as_str());
1715                    }
1716                });
1717            ui.end_row();
1718            ui.label("开平");
1719            ui.horizontal(|ui| {
1720                egui::ComboBox::from_id_salt("of_offset")
1721                    .selected_text(f.offset.as_str())
1722                    .show_ui(ui, |ui| {
1723                        for o in [
1724                            Offset::OPEN,
1725                            Offset::CLOSE,
1726                            Offset::CLOSETODAY,
1727                            Offset::CLOSEYESTERDAY,
1728                        ] {
1729                            ui.selectable_value(&mut f.offset, o, o.as_str());
1730                        }
1731                    });
1732                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
1733                ui.checkbox(&mut f.auto_split, "自动开平");
1734            });
1735            ui.end_row();
1736            ui.label("类型");
1737            egui::ComboBox::from_id_salt("of_order_type")
1738                .selected_text(f.order_type.as_str())
1739                .show_ui(ui, |ui| {
1740                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
1741                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
1742                    }
1743                });
1744            ui.end_row();
1745            ui.label("价格");
1746            ui.horizontal(|ui| {
1747                ui.add(
1748                    egui::TextEdit::singleline(&mut f.price)
1749                        .hint_text("107660")
1750                        .desired_width(90.0),
1751                );
1752                ui.checkbox(&mut f.follow_price, "随价");
1753            });
1754            ui.end_row();
1755            ui.label("数量");
1756            ui.add(
1757                egui::TextEdit::singleline(&mut f.volume)
1758                    .hint_text("1")
1759                    .desired_width(90.0),
1760            );
1761            ui.end_row();
1762            ui.label("接口");
1763            egui::ComboBox::from_id_salt("of_gateway")
1764                .selected_text(if f.gateway.is_empty() {
1765                    "(未选账户)".to_string()
1766                } else {
1767                    f.gateway.clone()
1768                })
1769                .show_ui(ui, |ui| {
1770                    for g in &gw_opts {
1771                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
1772                    }
1773                });
1774            ui.end_row();
1775            ui.label("操作");
1776            ui.horizontal(|ui| {
1777                // 无账户 = 无网关可下单(旧「空 → 字面量 ctp」兜底已退役,B4)。
1778                let send = ui
1779                    .add_enabled_ui(!no_accounts, |ui| {
1780                        crate::ui_ext::primary_button(ui, BTN_SEND_ORDER)
1781                    })
1782                    .inner
1783                    .clicked();
1784                if send {
1785                    match f.to_request() {
1786                        Ok(req) => {
1787                            let fallback = shared.default_gateway();
1788                            match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
1789                                Ok(gateway) => {
1790                                    shared.push_cmd(UiCmd::SendOrder {
1791                                        req,
1792                                        gateway,
1793                                        mode: if f.auto_split {
1794                                            ek_oms::LockMode::Auto
1795                                        } else {
1796                                            ek_oms::LockMode::None
1797                                        },
1798                                    });
1799                                    shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
1800                                }
1801                                Err(e) => {
1802                                    shared.log_line(format!("[下单] {e}"));
1803                                    err = Some(e);
1804                                }
1805                            }
1806                        }
1807                        Err(e) => {
1808                            shared.log_line(format!("[下单] {e}"));
1809                            err = Some(e);
1810                        }
1811                    }
1812                }
1813                if ui.button(BTN_CANCEL_ALL).clicked() {
1814                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
1815                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
1816                    let mut n = 0usize;
1817                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
1818                        // 撤单跟着**这笔单自己的网关**走(多账号面:不再回落 ctp)。
1819                        let gw = if !o.gateway_name.is_empty() {
1820                            o.gateway_name.clone()
1821                        } else {
1822                            shared.default_gateway().unwrap_or_default()
1823                        };
1824                        if gw.is_empty() {
1825                            continue;
1826                        }
1827                        shared.push_cmd(UiCmd::CancelOrder {
1828                            orderid: o.orderid.clone(),
1829                            gateway: gw,
1830                        });
1831                        n += 1;
1832                    }
1833                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
1834                }
1835            });
1836            ui.end_row();
1837            crate::ui_ext::muted_label(ui, "代码回车 = 订阅");
1838            ui.label("");
1839            ui.end_row();
1840        });
1841    if let Some(e) = err {
1842        ui.label(egui::RichText::new(e).color(crate::tokens::semantic::ERROR));
1843    }
1844    if no_accounts {
1845        // 侧栏底部独立行(不落钮行内:会把「全撤」推出侧栏宽域,s3 结构钉)。
1846        ui.label(
1847            egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
1848                .color(crate::tokens::semantic::WARN),
1849        );
1850    }
1851    if f != before {
1852        shared.set_order_form(f.clone());
1853    }
1854}
1855
1856/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
1857/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
1858/// 行情带跳色行指认,盘口帧不重复报幕)。
1859/// 盘口买 / 卖侧色。**仍是硬编码**(§7.1 记的两处:卖 `0x2E9E2E` / 买 `0xE05050`),
1860/// Wave B 的设计令牌层接管后这两支并进语义色,这里先集中成常量免得散落。
1861const BOOK_ASK_COLOR: egui::Color32 = crate::tokens::semantic::FALL;
1862const BOOK_BID_COLOR: egui::Color32 = crate::tokens::semantic::RISE;
1863
1864/// 盘口一行 = 三格:档位标签 / 价 / 量(BD-UI-55:egui_extras 退役,盘口换
1865/// `egui::Grid` 三列 —— 它只是一块自适应小表,没有表头/粘顶/角色,够不上
1866/// egui_table 的 delegate 拉动化;价与量右对齐仍走 `num_cell`)。
1867fn book_row(row: &mut egui::Ui, tag: String, color: egui::Color32, price: f64, volume: f64) {
1868    row.label(egui::RichText::new(tag).color(color));
1869    let _ = crate::ui_ext::num_cell(row, |ui| {
1870        ui.label(egui::RichText::new(crate::format::price(price)).color(color))
1871    });
1872    let _ = crate::ui_ext::num_cell(row, |ui| {
1873        ui.label(egui::RichText::new(crate::format::volume(volume)).color(color))
1874    });
1875}
1876
1877fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
1878    let f = shared.order_form();
1879    let Some(t) = tick_of(shared, &f.exchange, &f.symbol) else {
1880        crate::ui_ext::muted_label(ui, "(无 tick)");
1881        return;
1882    };
1883    // 0.2.29 Wave A 尾巴(BD-UI-45,毛病 6 的本体):五档盘口原来是一支文本
1884    // `ui.label("卖5  101.5  3")` —— 价与量靠**空格**对齐,位数一变(101.5 → 12345.5)
1885    // 整块就错位、贴左缘。换成三列小表(档 / 价 / 量),价与量右对齐成列。
1886    // 这里**不**用 `pinned_table`:下单侧是窄列 + 外层已有 ScrollArea,`pinned_table`
1887    // 的 `auto_shrink([false, false])` 会吃满整块可用高(0.2.17 s39 那条教训同型)。
1888    // 表头也不加:Python 侧没有五档盘口这个块(只有行情表的买1价/买1量/卖1价/卖1量
1889    // 四列),这是我们自加的常驻盘口,保持无表头。
1890    let (_, _head_h) = table_heights(ui);
1891    let asks: [(f64, f64); 5] = [
1892        (t.ask_price_5, t.ask_volume_5),
1893        (t.ask_price_4, t.ask_volume_4),
1894        (t.ask_price_3, t.ask_volume_3),
1895        (t.ask_price_2, t.ask_volume_2),
1896        (t.ask_price_1, t.ask_volume_1),
1897    ];
1898    let bids: [(f64, f64); 5] = [
1899        (t.bid_price_1, t.bid_volume_1),
1900        (t.bid_price_2, t.bid_volume_2),
1901        (t.bid_price_3, t.bid_volume_3),
1902        (t.bid_price_4, t.bid_volume_4),
1903        (t.bid_price_5, t.bid_volume_5),
1904    ];
1905    // `num_columns` 会让**最后一列吃掉父 ui 剩下的宽度**(egui grid.rs:144-154 +
1906    // Grid::num_columns 的注释)→ 「量」贴到面板右缘,价与量之间空出一大截。旧口径是
1907    // TableBuilder 三列 `Column::auto()`(列宽 = 内容)→ 这里不给列数,列宽回到内容驱动
1908    // (0.4.1 实锤 t8:十行里只有 3 个量格还在 x<280 的盘口块里)。
1909    egui::Grid::new("book_grid")
1910        .spacing([4.0, 0.0])
1911        .show(ui, |grid| {
1912            for (k, (p, v)) in asks.iter().enumerate() {
1913                book_row(grid, format!("卖{}", 5 - k), BOOK_ASK_COLOR, *p, *v);
1914                grid.end_row();
1915            }
1916            for (k, (p, v)) in bids.iter().enumerate() {
1917                book_row(grid, format!("买{}", k + 1), BOOK_BID_COLOR, *p, *v);
1918                grid.end_row();
1919            }
1920        });
1921}
1922
1923// ================= 数据格(2×2;0.4.1 回原生:tab 条在 render_topleft_group 等) =================
1924
1925/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
1926/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
1927/// 活动行撤单钮 + 双击撤单,终态行只读)。
1928fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
1929    let ts = shared.trade_snap();
1930    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
1931    let known_gws = account_gateways(shared);
1932    // 表题撤除(第五轮审计):格身份就是 dock tab,再挂同名 label = 三重撞名
1933    // (下单钮/tab/表题),纯冗余。
1934    let rows: Vec<_> = ts
1935        .orders
1936        .iter()
1937        .filter(|o| !active_only || order_active(o.status))
1938        .collect();
1939    // 委托表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1940    const ORDER_HEADERS: [&str; 17] = [
1941        "代码",
1942        "名称",
1943        "交易所",
1944        "类型",
1945        "方向",
1946        "开平",
1947        "价格",
1948        "总量",
1949        "已成",
1950        "成交均价",
1951        "状态",
1952        "状态描述",
1953        "时间",
1954        "委托号",
1955        "来源",
1956        "接口",
1957        "操作",
1958    ];
1959    // 0.3.3(收 Wave D 欠账):委托表也吃「点表头排序」—— 表 id 先算出来,
1960    // 空态与非空态共用一份(排序键住在 UiShared,按表 id 分槽)。
1961    let table_id = if active_only {
1962        "active_grid"
1963    } else {
1964        "order_grid"
1965    };
1966    let sort = shared.table_sort(table_id);
1967    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59):body 收出 0 行 = 空表,
1968    // 列宽由 `pinned_table_impl_cols` 按表头比例填满。
1969    // 排序只作用在「喂表前的快照副本」上(W2 口径,数据模型不被界面排序改写)。
1970    let rows = sorted_rows(&rows, sort, |o, col| order_sort_key(o, col));
1971    let clicked = pinned_table_sorted(ui, table_id, &ORDER_HEADERS, sort, |body, row_h| {
1972        const RIGHT_COLS: [usize; 4] = [6, 7, 8, 9];
1973        // move 格闭包要把这几样所有权收进去 —— 预先用引用整形(move 闭包拷贝引用,
1974        // 不搬 Vec;known_gws/rows 都活到调用帧外,E0382/E0505 实撞)。
1975        let known_gws = &known_gws;
1976        for o in &rows {
1977            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1978            // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
1979            // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
1980            let key = format!("{}.{}", o.gateway_name, o.orderid);
1981            let row_sel = shared.row_selected("orders", &key);
1982            body.row(row_h, row_sel, |r| {
1983                macro_rules! cell {
1984                    ($make:expr, $col:expr) => {{
1985                        // key 是**行内局部**:格延迟画笔要活到 show,必须自带 key 的
1986                        // 所有权(每格一份克隆;借用会随 add_body 帧悬空,E0373 实撞)。
1987                        let key = key.clone();
1988                        r.cell(move |ui, ctx| {
1989                            let (cell_r, inner_r) =
1990                                pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
1991                            let hit =
1992                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
1993                            if hit {
1994                                shared.select_row("orders", &key);
1995                            }
1996                            let dbl = cell_r.double_clicked()
1997                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
1998                            if dbl && order_active(o.status) {
1999                                // 双击撤单同按钮口径:跟着这笔单自己的网关(空串回落默认账户阶梯)。
2000                                let gw = if !o.gateway_name.is_empty() {
2001                                    o.gateway_name.clone()
2002                                } else {
2003                                    shared.default_gateway().unwrap_or_default()
2004                                };
2005                                if !gw.is_empty() {
2006                                    shared.push_cmd(UiCmd::CancelOrder {
2007                                        orderid: o.orderid.clone(),
2008                                        gateway: gw,
2009                                    });
2010                                }
2011                            }
2012                        });
2013                    }};
2014                }
2015                cell!(|ui| ui.label(o.symbol.clone()), 0);
2016                cell!(
2017                    |ui| ui.label(if o.name.is_empty() {
2018                        "-".to_string()
2019                    } else {
2020                        o.name.clone()
2021                    }),
2022                    1
2023                );
2024                cell!(|ui| ui.label(o.exchange.as_str().to_string()), 2);
2025                cell!(|ui| ui.label(o.order_type.as_str().to_string()), 3);
2026                cell!(
2027                    |ui| ui.label(
2028                        egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
2029                            if o.direction == Some(Direction::SHORT) {
2030                                crate::tokens::semantic::FALL
2031                            } else {
2032                                crate::tokens::semantic::RISE
2033                            },
2034                        ),
2035                    ),
2036                    4
2037                );
2038                cell!(|ui| ui.label(o.offset.as_str().to_string()), 5);
2039                cell!(|ui| ui.label(crate::format::price(o.price)), 6);
2040                cell!(|ui| ui.label(crate::format::volume(o.volume)), 7);
2041                cell!(|ui| ui.label(crate::format::volume(o.traded)), 8);
2042                cell!(|ui| ui.label(crate::format::price(o.traded_price)), 9);
2043                cell!(
2044                    |ui| ui.label(egui::RichText::new(o.status.as_str()).color(
2045                        if o.status == ek_data::constant::Status::REJECTED {
2046                            crate::tokens::semantic::RISE
2047                        } else if o.status == ek_data::constant::Status::ALLTRADED {
2048                            crate::tokens::semantic::FALL
2049                        } else {
2050                            crate::tokens::semantic::WARN
2051                        },
2052                    )),
2053                    10
2054                );
2055                cell!(|ui| ui.label(o.status_msg.clone()), 11);
2056                cell!(
2057                    |ui| ui.label(crate::format::time_hms_millis(o.datetime)),
2058                    12
2059                );
2060                cell!(|ui| ui.label(o.orderid.clone()), 13);
2061                cell!(
2062                    |ui| ui.label(if o.reference.is_empty() {
2063                        "-".to_string()
2064                    } else {
2065                        o.reference.clone()
2066                    }),
2067                    14
2068                );
2069                cell!(
2070                    |ui| ui.label(ledger_gw_label(&o.gateway_name, known_gws)),
2071                    15
2072                );
2073                // 「操作」列 = 活动行的撤单钮(延迟画笔:借用 o/key,出不了调用帧)。
2074                r.cell(|ui, _ctx| {
2075                    ui.horizontal(|ui| {
2076                        if order_active(o.status)
2077                            && ui
2078                                .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
2079                                .clicked()
2080                        {
2081                            // 撤单跟随委托的来路网关(行级 gateway_name;
2082                            // 多账号面:空串回落默认账户阶梯,不再硬编码 ctp)
2083                            let gw = if !o.gateway_name.is_empty() {
2084                                o.gateway_name.clone()
2085                            } else {
2086                                shared.default_gateway().unwrap_or_default()
2087                            };
2088                            if gw.is_empty() {
2089                                shared.log_line(format!(
2090                                    "[撤单] {} 无网关可撤(账户清册空)",
2091                                    o.orderid
2092                                ));
2093                            } else {
2094                                shared.push_cmd(UiCmd::CancelOrder {
2095                                    orderid: o.orderid.clone(),
2096                                    gateway: gw,
2097                                });
2098                            }
2099                        }
2100                    });
2101                });
2102            });
2103        }
2104    });
2105    if let Some(col) = clicked {
2106        shared.toggle_table_sort(table_id, col);
2107    }
2108}
2109
2110/// 委托行的排序键(列序 == `ORDER_HEADERS`):数字列给零填充串保证字典序 == 数值序
2111/// (`价格/总量/已成/成交均价`),时间列给纳秒零填充,其余给该列的文本。
2112/// 0.3.3 收 Wave D 欠账时新增 —— 与成交表的 `trade_sort_key` 同一口径。
2113fn order_sort_key(o: &ek_data::order::OrderData, col: usize) -> String {
2114    match col {
2115        0 => o.symbol.clone(),
2116        1 => o.name.clone(),
2117        2 => o.exchange.as_str().to_string(),
2118        3 => o.order_type.as_str().to_string(),
2119        4 => o
2120            .direction
2121            .map(|d| d.as_str().to_string())
2122            .unwrap_or_default(),
2123        5 => o.offset.as_str().to_string(),
2124        6 => format!("{:020.4}", o.price),
2125        7 => format!("{:020.4}", o.volume),
2126        8 => format!("{:020.4}", o.traded),
2127        9 => format!("{:020.4}", o.traded_price),
2128        10 => o.status.as_str().to_string(),
2129        11 => o.status_msg.clone(),
2130        12 => o.datetime.map(|ns| format!("{ns:025}")).unwrap_or_default(),
2131        13 => o.orderid.clone(),
2132        14 => o.reference.clone(),
2133        15 => o.gateway_name.clone(),
2134        // 第 17 列是「操作」= 撤单钮,没有可排的数据 → 空串(点它排序等于不动)。
2135        _ => String::new(),
2136    }
2137}
2138
2139/// 成交格(12 列逐值 = Python TradeMonitor 蓝图)。
2140pub(crate) fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
2141    // 单 tab(Python `tab_widget2.addTab(trade_widget, "成交")`:那区也是 QTabWidget,
2142    // 只有一个 tab 也带 tab 头 —— BD-UI-41 补齐;0.4.0 删过,0.4.1 回归原生后恢复)。
2143    tab_label(ui, true, "成交");
2144    let ts = shared.trade_snap();
2145    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
2146    let known_gws = account_gateways(shared);
2147    // 成交表列头单源(空/非空两分支同源,0.2.7 审计口径)。
2148    // 0.3.3 Wave F(BD-UI-51):列序照 Python `TradeMonitor.headers`(widget.py:1270-1283)——
2149    // 代码在首列,成交号在第 10 列;原来我们把「成交号」放首列,是差集里唯一一条纯 UI 差集。
2150    // 列名「成交额」→「成交金额」同 Python。
2151    const TRADE_HEADERS: [&str; 12] = [
2152        "代码",
2153        "名称",
2154        "交易所",
2155        "方向",
2156        "开平",
2157        "价格",
2158        "数量",
2159        "成交金额",
2160        "时间",
2161        "成交号",
2162        "委托号",
2163        "接口",
2164    ];
2165    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59,同未成交口径)。
2166    let sort = shared.table_sort("trade_grid");
2167    let rows = sorted_rows(&ts.trades, sort, trade_sort_key);
2168    let clicked = pinned_table_sorted(ui, "trade_grid", &TRADE_HEADERS, sort, |body, row_h| {
2169        const RIGHT_COLS: [usize; 3] = [5, 6, 7];
2170        // move 格闭包要把这几样所有权收进去 —— 预先引用整形(同委托表口径)。
2171        let rows = &rows;
2172        let known_gws = &known_gws;
2173        for t in rows.iter().copied() {
2174            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
2175            // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
2176            let key = format!("{}.{}", t.gateway_name, t.tradeid);
2177            let row_sel = shared.row_selected("trades", &key);
2178            body.row(row_h, row_sel, |r| {
2179                macro_rules! cell {
2180                    ($make:expr, $col:expr) => {{
2181                        // key 是行内局部:格延迟画笔要活到 show,每格自备克隆(见委托表)。
2182                        let key = key.clone();
2183                        r.cell(move |ui, ctx| {
2184                            let (cell_r, inner_r) =
2185                                pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
2186                            let hit =
2187                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
2188                            if hit {
2189                                shared.select_row("trades", &key);
2190                            }
2191                            // 0.3.1(Wave D,BD-UI-49)双击联动:双击成交行 = 该合约进下单面板
2192                            // (Python 双击成交/持仓行带入下单,`mainwindow.py:276` 等价)。
2193                            let dbl = cell_r.double_clicked()
2194                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
2195                            if dbl {
2196                                let mut f = shared.order_form();
2197                                f.symbol = t.symbol.clone();
2198                                if let Some(d) = t.direction {
2199                                    f.direction = d;
2200                                }
2201                                shared.set_order_form(f);
2202                            }
2203                            // 0.3.1(Wave D)右键菜单(照 Python 表上右键:调整列宽 / 保存数据)。
2204                            // 只挂首列 = 一行一颗菜单,不在每格挂一颗。
2205                            // 0.3.3(收 Wave D 欠账):菜单挂在**真控件**上 —— 首格内容是
2206                            // `Label::new(..).sense(Sense::click())`(有 AccessKit 动作的真控件),
2207                            // 菜单由它自己的 `context_menu` 开。
2208                            // 0.4.1 五期(BD-UI-58):菜单里的「调整列宽」退役 —— 列边界可拖
2209                            // 与列线现在是全部表的固定形制(见 `pinned_table_impl_cols`),
2210                            // 一个只影响这一张表、还只影响「能不能拖」的开关成了空壳钮。
2211                        });
2212                    }};
2213                }
2214                // 首格是**真控件**(可点 + 可右键):右键菜单挂在这颗 Label 上。
2215                cell!(
2216                    |ui| {
2217                        let r = ui
2218                            .add(egui::Label::new(t.symbol.clone()).sense(egui::Sense::click()))
2219                            .on_hover_text("右键:保存数据");
2220                        r.context_menu(|ui| {
2221                            if ui.button("保存数据").clicked() {
2222                                let _ =
2223                                    save_table_csv("trade_grid", &TRADE_HEADERS, rows, trade_cells);
2224                                ui.close();
2225                            }
2226                        });
2227                        r
2228                    },
2229                    0
2230                );
2231                cell!(
2232                    |ui| ui.label(if t.name.is_empty() {
2233                        "-".to_string()
2234                    } else {
2235                        t.name.clone()
2236                    }),
2237                    1
2238                );
2239                cell!(|ui| ui.label(t.exchange.as_str().to_string()), 2);
2240                cell!(
2241                    |ui| ui.label(
2242                        egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
2243                            if t.direction == Some(Direction::SHORT) {
2244                                crate::tokens::semantic::FALL
2245                            } else {
2246                                crate::tokens::semantic::RISE
2247                            },
2248                        ),
2249                    ),
2250                    3
2251                );
2252                cell!(|ui| ui.label(t.offset.as_str().to_string()), 4);
2253                cell!(|ui| ui.label(crate::format::price(t.price)), 5);
2254                cell!(|ui| ui.label(crate::format::volume(t.volume)), 6);
2255                cell!(|ui| ui.label(crate::format::price(t.traded_amount)), 7);
2256                cell!(|ui| ui.label(crate::format::time_hms_millis(t.datetime)), 8);
2257                cell!(|ui| ui.label(t.tradeid.clone()), 9);
2258                cell!(|ui| ui.label(t.orderid.clone()), 10);
2259                cell!(
2260                    |ui| ui.label(ledger_gw_label(&t.gateway_name, known_gws)),
2261                    11
2262                );
2263            });
2264        }
2265        // 右键菜单已挂在第 1 格那颗真控件上(见体表 cell!(.., 0))。
2266    });
2267    if let Some(col) = clicked {
2268        shared.toggle_table_sort("trade_grid", col);
2269    }
2270}
2271
2272/// 持仓格(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
2273/// Python mainwindow.py:276 等价,不开 K线)。
2274pub(crate) fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
2275    // 单 tab(Python `tab_widget3.addTab(position_widget, "持仓")`;BD-UI-41 补齐,
2276    // 0.4.0 删过,0.4.1 回归原生后恢复)。
2277    tab_label(ui, true, "持仓");
2278    let ts = shared.trade_snap();
2279    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
2280    let known_gws = account_gateways(shared);
2281    // 持仓表列头单源(空/非空两分支同源,0.2.7 审计口径)。
2282    const POS_HEADERS: [&str; 16] = [
2283        "代码",
2284        "名称",
2285        "交易所",
2286        "方向",
2287        "数量",
2288        "昨仓",
2289        "可用",
2290        "冻结",
2291        "在途",
2292        "市值",
2293        "成本",
2294        "均价",
2295        "最新价",
2296        "盈亏",
2297        "盈亏比",
2298        "接口",
2299    ];
2300    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59,同未成交口径)。
2301    // 0.3.3(收 Wave D 欠账):持仓表也吃「点表头排序」。
2302    let sort = shared.table_sort("pos_grid");
2303    let rows = sorted_rows(&ts.positions, sort, pos_sort_key);
2304    let clicked = pinned_table_sorted(ui, "pos_grid", &POS_HEADERS, sort, |body, row_h| {
2305        const RIGHT_COLS: [usize; 11] = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
2306        // move 格闭包要把这几样所有权收进去 —— 预先引用整形(同委托表口径)。
2307        let known_gws = &known_gws;
2308        for p in &rows {
2309            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
2310            // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
2311            // 不开 K线;方向随持仓方向,Python 同口径)。
2312            let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
2313            let row_sel = shared.row_selected("positions", &key);
2314            body.row(row_h, row_sel, |r| {
2315                macro_rules! cell {
2316                    ($make:expr, $col:expr) => {{
2317                        // key 是行内局部:格延迟画笔要活到 show,每格自备克隆(见委托表)。
2318                        let key = key.clone();
2319                        r.cell(move |ui, ctx| {
2320                            let (cell_r, inner_r) =
2321                                pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
2322                            let hit =
2323                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
2324                            if hit {
2325                                shared.select_row("positions", &key);
2326                            }
2327                            let dbl = cell_r.double_clicked()
2328                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
2329                            if dbl {
2330                                let mut f = shared.order_form();
2331                                f.symbol = p.symbol.clone();
2332                                f.exchange = p.exchange.as_str().to_string();
2333                                f.direction = p.direction;
2334                                // B1 修:行级网关带入(旧实现不带网关 → 平掉哪条腿由上一单残留决定)。
2335                                if !p.gateway_name.is_empty() {
2336                                    f.gateway = p.gateway_name.clone();
2337                                }
2338                                shared.set_order_form(f);
2339                                shared.log_line(format!(
2340                                    "[ops] 持仓带入下单面板 {}(接口 {})",
2341                                    p.symbol, p.gateway_name
2342                                ));
2343                            }
2344                        });
2345                    }};
2346                }
2347                cell!(|ui| ui.label(p.symbol.clone()), 0);
2348                cell!(
2349                    |ui| ui.label(if p.name.is_empty() {
2350                        "-".to_string()
2351                    } else {
2352                        p.name.clone()
2353                    }),
2354                    1
2355                );
2356                cell!(|ui| ui.label(p.exchange.as_str().to_string()), 2);
2357                cell!(
2358                    |ui| ui.label(egui::RichText::new(p.direction.as_str()).color(
2359                        if p.direction == ek_data::constant::Direction::SHORT {
2360                            crate::tokens::semantic::FALL
2361                        } else {
2362                            crate::tokens::semantic::RISE
2363                        },
2364                    )),
2365                    3
2366                );
2367                cell!(|ui| ui.label(crate::format::volume(p.volume)), 4);
2368                cell!(|ui| ui.label(crate::format::volume(p.yd_volume)), 5);
2369                cell!(|ui| ui.label(crate::format::volume(p.can_use_volume)), 6);
2370                cell!(|ui| ui.label(crate::format::volume(p.frozen)), 7);
2371                cell!(|ui| ui.label(crate::format::volume(p.on_road_volume)), 8);
2372                cell!(|ui| ui.label(crate::format::price(p.market_value)), 9);
2373                // 0.3.3 Wave F(BD-UI-51)两处收口:
2374                // ① 成本 / 均价原来**取值互换** —— ek-data 里 `avg_price` = 成本价、
2375                //    `price` = 持仓均价(position.rs:22-23 / 38-39),Python 也是
2376                //    成本 ← avg_price、均价 ← price(widget.py:1491-1492)。判据 F2 抓到这条。
2377                // ② 五处裸 `format!("{:.2}")` / `{:+.1}` / `{:+.4}` 改走 `format.rs`
2378                //    (Python BaseCell 是 `round(content, 3)`,同一张表里三种精度就是毛病 3)。
2379                cell!(|ui| ui.label(crate::format::price(p.avg_price)), 10);
2380                cell!(|ui| ui.label(crate::format::price(p.price)), 11);
2381                cell!(|ui| ui.label(crate::format::price(p.last_price)), 12);
2382                cell!(
2383                    |ui| ui.label(egui::RichText::new(crate::format::price(p.pnl)).color(
2384                        if p.pnl < 0.0 {
2385                            crate::tokens::semantic::FALL
2386                        } else {
2387                            crate::tokens::semantic::RISE
2388                        }
2389                    ),),
2390                    13
2391                );
2392                cell!(|ui| ui.label(crate::format::price(p.profit_rate)), 14);
2393                cell!(
2394                    |ui| ui.label(ledger_gw_label(&p.gateway_name, known_gws)),
2395                    15
2396                );
2397            });
2398        }
2399    });
2400    if let Some(col) = clicked {
2401        shared.toggle_table_sort("pos_grid", col);
2402    }
2403}
2404
2405/// 持仓行的排序键(列序 == `POS_HEADERS`;与屏幕取值同源:
2406/// 成本 = `avg_price`、均价 = `price`,0.3.3 Wave F 修的那一对)。
2407fn pos_sort_key(p: &ek_data::position::PositionData, col: usize) -> String {
2408    match col {
2409        0 => p.symbol.clone(),
2410        1 => p.name.clone(),
2411        2 => p.exchange.as_str().to_string(),
2412        3 => p.direction.as_str().to_string(),
2413        4 => format!("{:020.4}", p.volume),
2414        5 => format!("{:020.4}", p.yd_volume),
2415        6 => format!("{:020.4}", p.can_use_volume),
2416        7 => format!("{:020.4}", p.frozen),
2417        8 => format!("{:020.4}", p.on_road_volume),
2418        9 => format!("{:020.4}", p.market_value),
2419        10 => format!("{:020.4}", p.avg_price),
2420        11 => format!("{:020.4}", p.price),
2421        12 => format!("{:020.4}", p.last_price),
2422        13 => format!("{:020.4}", p.pnl),
2423        14 => format!("{:020.4}", p.profit_rate),
2424        15 => p.gateway_name.clone(),
2425        _ => String::new(),
2426    }
2427}
2428
2429/// 资金格(0.4.0:独立 dock 格 —— 原来它是「资金|日志」组内 tab 的一半)。
2430pub(crate) fn render_funds_pane(ui: &mut egui::Ui, shared: &UiShared) {
2431    let ts = shared.trade_snap();
2432    // 0.2.29(BD-UI-44):资金表跟着日志表换 TableBuilder —— 同一套固定表头 + 按内容
2433    // 列宽;余额/可用/冻结是数字列(右对齐成列)。
2434    //
2435    // 0.4.0 修正(用户实看:「资金…表头都没有」):空态**不得吞掉表头**。原来这里
2436    // `if accounts.is_empty() { muted_label; return; }` —— 没有网关时整张表连表头一起
2437    // 消失,而它就是「资金|日志」格默认前台的那一格,用户第一眼看到的就是一句空态。
2438    // 口径统一成「表头恒在」(委托/成交/持仓/日志同此);0.4.1 六期(BD-UI-59)起空态
2439    // 那一行提示文字也撤了 —— 表体就是空的,列宽按表头比例填满。
2440    pinned_table_impl(
2441        ui,
2442        "acct_grid",
2443        &["账号", "接口", "余额", "可用", "冻结"],
2444        None,
2445        |body, row_h| {
2446            const RIGHT_COLS: [usize; 3] = [2, 3, 4];
2447            for a in &ts.accounts {
2448                body.row(row_h, false, |r| {
2449                    macro_rules! cell {
2450                        ($make:expr, $col:expr) => {{
2451                            r.cell(|ui, ctx| {
2452                                let _ = pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
2453                            });
2454                        }};
2455                    }
2456                    cell!(|ui| ui.label(a.accountid.clone()), 0);
2457                    cell!(|ui| ui.label(a.gateway_name.clone()), 1);
2458                    // 0.3.3 Wave F:三处裸 `{:.2}` 改走 `format.rs`(同一口径,毛病 3)。
2459                    cell!(|ui| ui.label(crate::format::price(a.balance)), 2);
2460                    cell!(|ui| ui.label(crate::format::price(a.available())), 3);
2461                    cell!(|ui| ui.label(crate::format::price(a.frozen)), 4);
2462                });
2463            }
2464        },
2465    );
2466}
2467
2468/// 日志表(BD-UI-41,对照 Python 版)。
2469///
2470/// Python 侧「日志」tab 里放的是 `LogMonitor` —— 一个带 **时间/信息/接口** 表头的
2471/// QTableWidget(`widget.py:1167-1180`,BaseMonitor 子类),跟着 Dock 一起伸展。
2472/// 我们原来是一坨 monospace 行 + `ScrollArea::max_height(180.0)` 硬顶:窗口最大化
2473/// 它还是 180px —— 用户报的「整体放到最大,日志那边表格还是很小」根因就是那 180。
2474/// 现在走 `pinned_table`(与委托/成交/持仓同一套容器:表头固定 + 体滚 + 横滚),
2475/// 硬顶撤除 → 格多大,表多大。
2476///
2477/// 两处偏离(记 DESIGN BD-UI-41):
2478/// ① Python 有第三列「接口」;我们的 `log_line` 调用点不带网关(正文自带 `[ops]` /
2479///   `[订阅]` 这类前缀),硬造一列空列不如不造,所以只有两列。
2480/// ② `stick_to_bottom` 撤除:Python 的 QTableWidget 也不自动跟到最后一行,
2481///   要看最新行自己滚到底(与那张表同口径)。
2482fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
2483    // 「清空日志」钮已撤除(0.4.1 用户裁定 2026-10-22:日志面不要这个钮)。
2484    // 状态层的 `UiShared::clear_logs()` 保留(它是状态口,测试与后续「日志面重置」
2485    // 都用得上),只是界面不再给入口。
2486    let (entries, spilled) = shared.logs_entries();
2487    if spilled > 0 {
2488        // 口径写明(G1 实连证据的收口):回扫日志取证不可靠 —— 巡回里「重加后 90 秒
2489        // 没抓到『交易服务器登录成功』行」不是没登录,是这一环被状态推送流挤满了。
2490        // 登录/掉线/查询被拒这些事实的事实源是账户面板的状态列与状态细节(网关状态口),
2491        // 不是日志窗。单独一行,不挤在钮旁:长文案在横向行里会被压(s39 那类实锤)。
2492        ui.label(format!(
2493            "已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里",
2494            crate::state::RING_CAP
2495        ));
2496    }
2497    // BD-UI-55(egui_table 迁入):日志正文逐帧生长 → 列宽走**接管版**(字体量串,
2498    // egui galley 缓存受益;不走库 per-frame auto-size,那条路 = 每帧双 pass,
2499    // 行动作用闭包会跑两遍 —— 适配层注释)。时间列定宽 90,信息列 = 最宽正文。
2500    let mono_font = egui::TextStyle::Monospace.resolve(ui.style());
2501    // 空态不接管列宽 —— 走「按表头比例分配」那条路(BD-UI-58)。
2502    let widths: Option<Vec<f32>> = if entries.is_empty() {
2503        None
2504    } else {
2505        Some(vec![
2506            {
2507                let g = ui.painter().layout_no_wrap(
2508                    "18:00:00.000".to_string(),
2509                    mono_font.clone(),
2510                    egui::Color32::TRANSPARENT,
2511                );
2512                g.size().x + 12.0
2513            },
2514            {
2515                let mut w: f32 = 120.0;
2516                for e in &entries {
2517                    let g = ui.painter().layout_no_wrap(
2518                        e.text.clone(),
2519                        mono_font.clone(),
2520                        egui::Color32::TRANSPARENT,
2521                    );
2522                    w = w.max(g.size().x + 12.0);
2523                }
2524                w
2525            },
2526        ])
2527    };
2528    pinned_table_impl_cols(
2529        ui,
2530        "log_grid",
2531        &["时间", "信息"],
2532        None,
2533        widths,
2534        |body, row_h| {
2535            for e in &entries {
2536                body.row(row_h, false, |r| {
2537                    r.cell(|ui, _ctx| {
2538                        ui.monospace(e.at.clone());
2539                    });
2540                    r.cell(|ui, _ctx| {
2541                        ui.monospace(e.text.clone());
2542                    });
2543                });
2544            }
2545        },
2546    );
2547}
2548
2549// ================= 独立窗面(BD-UI-32/33)=================
2550
2551/// 独立窗几何(BD-UI-34:助手参数收敛,8→6 闸 clippy too_many_arguments)。
2552struct WinGeom {
2553    inner: [f32; 2],
2554    min_inner: [f32; 2],
2555    maximized: bool,
2556}
2557
2558/// 独立 OS 窗统一开法(BD-UI-33,四窗一型:运维/CTP/查询合约/K线)。
2559///
2560/// - **守卫(0.2.3 教训内建):** 调用方按状态位辞行——位关不调本函数 =
2561///   viewport 本拍未 used = egui/eframe GC 销毁 OS 窗(销毁判据源码
2562///   context.rs「never used this pass」)。
2563/// - Embedded 回退(后端不支持多窗:kittest/WEB)= 主窗内 egui::Window 同本体,
2564///   回退面关闭 = embedded 闭包返回 true。
2565/// - Immediate 面 OS X = close_requested → 返回 true(调用方翻负状态位)。
2566/// - `geom.maximized` = 打开即最大化(BD-UI-34,K线窗用户裁定;OS 窗属性,
2567///   Embedded 回退面无此概念 = 忽略)。
2568/// - `body` 返回 true = 内容侧请求关闭(BD-UI-34:K线窗 ESC 键快捷关闭;
2569///   键事件按子窗焦点路由,主窗按键不会误关)。
2570///
2571/// 返回 = "本拍该窗被关"(调用方负责状态位/列表出表)。
2572fn show_independent_window(
2573    ctx: &egui::Context,
2574    id_salt: impl std::hash::Hash,
2575    title: &str,
2576    geom: &WinGeom,
2577    embedded: impl Fn(&egui::Context) -> bool,
2578    body: impl Fn(&mut egui::Ui) -> bool,
2579) -> bool {
2580    let closed = std::cell::Cell::new(false);
2581    let mut builder = egui::ViewportBuilder::default()
2582        .with_title(title)
2583        .with_inner_size(geom.inner)
2584        .with_min_inner_size(geom.min_inner)
2585        .with_minimize_button(true)
2586        .with_maximize_button(true)
2587        .with_close_button(true);
2588    if geom.maximized {
2589        builder = builder.with_maximized(true);
2590    }
2591    ctx.show_viewport_immediate(
2592        egui::ViewportId::from_hash_of(id_salt),
2593        builder,
2594        |ctx2, class| {
2595            if class == egui::ViewportClass::Embedded {
2596                closed.set(embedded(ctx2));
2597                return;
2598            }
2599            if ctx2.input(|i| i.viewport().close_requested()) {
2600                closed.set(true);
2601                return;
2602            }
2603            egui::CentralPanel::default().show(ctx2, |ui| {
2604                if body(ui) {
2605                    closed.set(true);
2606                }
2607            });
2608        },
2609    );
2610    closed.get()
2611}
2612
2613fn render_windows(ctx: &egui::Context, shared: &UiShared) {
2614    // 运维窗(菜单「运维」;BD-UI-33 转独立 OS 窗,用户裁定「四窗皆可脱离主窗」)。
2615    if shared.ops_window_open() {
2616        let closed = show_independent_window(
2617            ctx,
2618            "ops_win",
2619            "运维窗",
2620            &WinGeom {
2621                inner: [680.0, 420.0],
2622                min_inner: [420.0, 260.0],
2623                maximized: false,
2624            },
2625            |ctx2| {
2626                let mut open = shared.ops_window_open();
2627                egui::Window::new("运维窗")
2628                    .id(egui::Id::new("ops_win"))
2629                    .default_pos([80.0, 320.0])
2630                    .default_size([640.0, 380.0])
2631                    .open(&mut open)
2632                    .show(ctx2, |ui| {
2633                        render_ops_window(ui, shared);
2634                    });
2635                shared.set_ops_window_open(open);
2636                !open
2637            },
2638            |ui| {
2639                render_ops_window(ui, shared);
2640                false
2641            },
2642        );
2643        if closed {
2644            shared.set_ops_window_open(false);
2645        }
2646    }
2647
2648    // 账户面板(菜单「账户」;多账号面:账户清单 + 行情源选择 + 添加/编辑表单;
2649    // 旧「CTP 网关配置窗」退役收纳于此窗,BD-UI-33 独立 OS 窗形态沿用)。
2650    if shared.accounts_window_open() {
2651        let closed = show_independent_window(
2652            ctx,
2653            "account_win",
2654            "账户面板",
2655            &WinGeom {
2656                inner: [760.0, 520.0],
2657                min_inner: [520.0, 320.0],
2658                maximized: false,
2659            },
2660            |ctx2| {
2661                let mut open = shared.accounts_window_open();
2662                egui::Window::new("账户面板")
2663                    .id(egui::Id::new("account_win"))
2664                    .default_pos([640.0, 320.0])
2665                    .default_size([720.0, 470.0])
2666                    .open(&mut open)
2667                    .show(ctx2, |ui| {
2668                        render_accounts_window(ui, shared);
2669                    });
2670                shared.set_accounts_window_open(open);
2671                !open
2672            },
2673            |ui| {
2674                render_accounts_window(ui, shared);
2675                false
2676            },
2677        );
2678        if closed {
2679            shared.set_accounts_window_open(false);
2680        }
2681    }
2682
2683    // 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
2684    // 『添加账户』与『编辑 <账户>』开同一个弹窗的两种模式;**非 modal**(不阻塞主窗与行情观察)。
2685    // 关闭(窗 × 或『取消』)= 放弃未保存(规划 Q24),绝不静默写入清册留档。
2686    if shared.account_edit_open() {
2687        let closed = show_independent_window(
2688            ctx,
2689            "account_edit_win",
2690            "账户配置",
2691            &WinGeom {
2692                inner: [680.0, 340.0],
2693                min_inner: [480.0, 240.0],
2694                maximized: false,
2695            },
2696            |ctx2| {
2697                let mut open = shared.account_edit_open();
2698                let title = match shared.editing_account() {
2699                    Some(gw) => format!("账户配置 — 编辑 {gw}"),
2700                    None => "账户配置 — 添加".to_string(),
2701                };
2702                egui::Window::new(title)
2703                    .id(egui::Id::new("account_edit_win"))
2704                    .default_pos([700.0, 380.0])
2705                    .default_size([660.0, 320.0])
2706                    .open(&mut open)
2707                    .show(ctx2, |ui| {
2708                        render_account_edit_window(ui, shared);
2709                    });
2710                if !open {
2711                    shared.end_account_edit();
2712                }
2713                !open
2714            },
2715            |ui| {
2716                render_account_edit_window(ui, shared);
2717                false
2718            },
2719        );
2720        if closed && shared.account_edit_open() {
2721            shared.end_account_edit();
2722        }
2723    }
2724
2725    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)。
2726    if shared.contracts_window_open() {
2727        let closed = show_independent_window(
2728            ctx,
2729            "contracts_win",
2730            "查询合约窗",
2731            &WinGeom {
2732                inner: [620.0, 460.0],
2733                min_inner: [420.0, 260.0],
2734                maximized: false,
2735            },
2736            |ctx2| {
2737                let mut open = shared.contracts_window_open();
2738                egui::Window::new("查询合约窗")
2739                    .id(egui::Id::new("contracts_win"))
2740                    .default_pos([420.0, 300.0])
2741                    .default_size([560.0, 420.0])
2742                    .open(&mut open)
2743                    .show(ctx2, |ui| {
2744                        render_contracts_window(ui, shared);
2745                    });
2746                shared.set_contracts_window_open(open);
2747                !open
2748            },
2749            |ui| {
2750                render_contracts_window(ui, shared);
2751                false
2752            },
2753        );
2754        if closed {
2755            shared.set_contracts_window_open(false);
2756        }
2757    }
2758
2759    // K线窗(双击弹出,按合约多开;出 kline_symbols 列表 = 不再 show = 销毁)。
2760    // BD-UI-34:打开即最大化(maximized)+ ESC 键快捷关闭(body 请求关闭;
2761    // 键按子窗焦点路由,主窗按键不误关)。
2762    let mut closed: Vec<String> = Vec::new();
2763    for sym in shared.kline_symbols() {
2764        let is_closed = show_independent_window(
2765            ctx,
2766            format!("kline_win:{sym}"),
2767            &format!("K线 — {sym}"),
2768            &WinGeom {
2769                inner: [560.0, 360.0],
2770                min_inner: [360.0, 220.0],
2771                maximized: true,
2772            },
2773            |ctx2| {
2774                let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
2775                let resp = egui::Window::new(format!("K线 — {sym}"))
2776                    .id(egui::Id::new(("kline_win", sym.clone())))
2777                    .default_pos([300.0, 340.0])
2778                    .default_size([540.0, 330.0])
2779                    .show(ctx2, |ui| {
2780                        let cell = shared
2781                            .market()
2782                            .cells()
2783                            .into_iter()
2784                            .find(|c| c.symbol == sym);
2785                        render_kline_cell(ui, cell.as_ref());
2786                    });
2787                esc || resp.is_none()
2788            },
2789            |ui| {
2790                if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
2791                    return true;
2792                }
2793                let cell = shared
2794                    .market()
2795                    .cells()
2796                    .into_iter()
2797                    .find(|c| c.symbol == sym);
2798                render_kline_cell(ui, cell.as_ref());
2799                false
2800            },
2801        );
2802        if is_closed {
2803            closed.push(sym);
2804        }
2805    }
2806    for s in closed {
2807        shared.close_kline(&s);
2808    }
2809}
2810
2811/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
2812/// + 依赖图。回灌界面面已退役(总线口保留;流控查询钮 0.2.10 随 sim 退场撤除)。
2813fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
2814    crate::ui_ext::section_title(ui, "插件");
2815    // 0.5 M3:装配显形改吃事件(启动 / `ePlugUp.oms`),这里只留一个手动口子。
2816    // 为什么放在「插件」节而不是「oms 管线」节:问不到时那一节整节不画(s87b 口径),
2817    // 钮挂在那儿就等于「有读数才给重问」—— 恰恰在没读数的时候最需要重问。
2818    ui.horizontal(|ui| {
2819        if ui.small_button(BTN_PIPELINE_REFRESH).clicked() {
2820            shared.request_pipeline_refresh();
2821        }
2822        ui.label(
2823            egui::RichText::new("管线读数只在 oms 上线 / 启动 / 这里手动触发时问一次")
2824                .color(crate::tokens::semantic::INFO),
2825        );
2826    });
2827    let snap = shared.snapshot();
2828    // 账户清册里的实例名(一次取好,别在每行每帧各 clone 一遍整表)。
2829    let account_gateways: std::collections::HashSet<String> = shared
2830        .accounts()
2831        .iter()
2832        .map(|a| a.gateway.clone())
2833        .collect();
2834    let mut plug_heads: Vec<(egui::Rect, String)> = Vec::new();
2835    let mut plug_rows: Vec<f32> = Vec::new();
2836    // 列宽下限(BD-UI-60:短表表宽 = 所在区域 100%,口径与实现见 `ui_ext::grid_col_width`)。
2837    let plug_col_w = crate::ui_ext::grid_col_width(ui, "plug_grid", 6);
2838    // 行高下限与表格层同一口径(BD-UI-61:`table_heights` 同一对数,短表行高 = 主表行高)。
2839    let plug_row_h = table_heights(ui).0;
2840    egui::Grid::new("plug_grid")
2841        .num_columns(6)
2842        .min_col_width(plug_col_w)
2843        .min_row_height(plug_row_h)
2844        .show(ui, |ui| {
2845            for h in ["名称", "状态", "版本", "依赖", "统计", "操作"] {
2846                let r = ui.label(egui::RichText::new(h).strong().small());
2847                plug_heads.push((r.rect, h.to_string()));
2848            }
2849            ui.end_row();
2850            // 空态不再画提示行(0.4.1 六期 BD-UI-59):快照空 = 这张表只有表头那一行。
2851            for r in &snap.rows {
2852                if is_quarantined(&r.state) {
2853                    ui.label(
2854                        egui::RichText::new(r.name.clone()).color(crate::tokens::semantic::ERROR),
2855                    );
2856                    ui.label(
2857                        egui::RichText::new(state_text(r))
2858                            .color(crate::tokens::semantic::ERROR)
2859                            .strong(),
2860                    );
2861                } else {
2862                    ui.label(r.name.clone());
2863                    ui.label(state_text(r));
2864                }
2865                ui.label(r.version.clone());
2866                ui.label(format!("deps:[{}]", r.deps.join(",")));
2867                // 统计列(0.4.5,P7d):溢出与 panic 计数此前与其余列同色,读的人要逐格找。
2868                // 现在按最坏的那一项上色:0/0 走 MUTED(它是「没事」的意思,不是数据),
2869                // 溢出 > 0 走 WARN,溢出跨过邮箱容量或有任何 panic 走 ERROR。
2870                // 只上色不改布局(这一格仍是一个 label,列对齐与表头带几何都不动)。
2871                ui.label(
2872                    egui::RichText::new(format!("spilled:{} panics:{}", r.spilled, r.panics))
2873                        .color(stat_color(
2874                            r.spilled,
2875                            r.panics,
2876                            crate::state::mailbox_cap_of(&r.name) as u64,
2877                        )),
2878                );
2879                ui.horizontal(|ui| {
2880                    // Q17 收口(0.2.20):账户清册里的实例不在运维窗起停。
2881                    // 账户面板的『移除』带全套守卫(活跃委托闸 + 二次确认 + 持仓提示),
2882                    // 运维窗这条是同一动作的无守卫副本 —— 同一个实例两条移除路、守卫只长在
2883                    // 一条上,等于守卫可以绕过(规划 Q17 的边界就在这)。
2884                    // 清册外的 `ctp_*` 实例(手工起的 / 旧流程残留)仍留这条出口,否则没有退路。
2885                    if account_gateways.contains(&r.name) {
2886                        ui.label(
2887                            egui::RichText::new("起停归『账户』面板(带撤单守卫)")
2888                                .color(crate::tokens::semantic::INFO),
2889                        );
2890                    } else if r.name == UI_NAME || r.name == SVCTL_NAME {
2891                        // 管制面与观测面自身不给移除钮(0.4.3 BD-UI-64,审核 P1)。
2892                        // 摘 svctl = 单向门:工厂表(default_kinds / ui_kinds)都不登记 "svctl",
2893                        // 摘掉后连『重注入 svctl』也只会回 unknown factory,启停面没有恢复入口。
2894                        // 摘 ui = 自己等自己:这条指令正是 ui 自己的 worker 在执行,只能走到
2895                        // ek-core close_one 的 2 秒超时 detach 分支,窗口还在但不再刷新。
2896                        // 插件侧同一道闸:supervisor 拒摘 svctl(ek-station 0.2.11)、
2897                        // UiPlugin 直拒摘 ui(本 crate plugin.rs)。
2898                        ui.label(
2899                            egui::RichText::new("管制面/观测面自身:不在运维窗摘除")
2900                                .color(crate::tokens::semantic::INFO),
2901                        );
2902                        if ui.small_button(format!("重注入 {}", r.name)).clicked() {
2903                            shared.push_cmd(UiCmd::Start {
2904                                factory: r.name.clone(),
2905                                name: String::new(),
2906                            });
2907                        }
2908                    } else {
2909                        if ui.small_button(format!("重注入 {}", r.name)).clicked() {
2910                            // 运维窗按「注册名 = 工厂名」的旧插件重注入(dm/oms/ui);
2911                            // 命名网关实例(`ctp_<broker>_<账号>`)不是工厂名,会被
2912                            // supervisor 回拒 unknown factory —— 那类实例的起停归「账户」面板。
2913                            shared.push_cmd(UiCmd::Start {
2914                                factory: r.name.clone(),
2915                                name: String::new(),
2916                            });
2917                        }
2918                        let armed = shared.pending_confirm();
2919                        let armed_here = ui
2920                            .small_button(remove_button_label(&r.name, armed.as_deref()))
2921                            .clicked()
2922                            && shared.confirm_step(&r.name) == ConfirmStep::Fired;
2923                        if armed_here {
2924                            shared.push_cmd(UiCmd::Remove {
2925                                name: r.name.clone(),
2926                            });
2927                        }
2928                    }
2929                });
2930                plug_rows.push(ui.min_rect().max.y);
2931                ui.end_row();
2932            }
2933        });
2934    let plug_band = header_band(ui, &plug_heads, ui.min_rect().max.x);
2935    grid_chrome(ui, plug_band, &plug_heads, &plug_rows);
2936
2937    if let Some(name) = shared.pending_confirm() {
2938        ui.horizontal(|ui| {
2939            ui.label(
2940                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
2941                    .color(crate::tokens::semantic::WARN),
2942            );
2943            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2944                shared.confirm_cancel();
2945            }
2946        });
2947    }
2948
2949    // oms 管线(0.4.4,P5-a 裁定接的那条服务):装配显形。
2950    // 插件表给「谁在场」,这一节给「oms 里面装了什么」——五段管线 / 限速与撤单守卫 /
2951    // 拆单器 / 每道风控闸一行(工厂注入 ek_risk::standard_gates 缺省限额那四道)。
2952    // 问不到(oms 不在场 → TargetGone 快错,或超时)整节不画:这里没有表、没有可空的
2953    // 东西,与六期 BD-UI-59「空态 = 表头 + 0 行」不是同一种空。
2954    let pipeline = shared.pipeline_lines();
2955    if !pipeline.is_empty() {
2956        crate::ui_ext::space_group(ui);
2957        crate::ui_ext::section_title(ui, "oms 管线");
2958        for line in &pipeline {
2959            ui.label(egui::RichText::new(line).monospace());
2960        }
2961    }
2962
2963    // 配置托底(0.6.0,BD-CFG 环节8):段落目录(零值面)+ 清段/重存段。
2964    // save 联线的另一半长在网关自己身上(`{gw}.configure` 受理成功 → 三行序,
2965    // ek-station DESIGN BD-CFG-14 的 0.10.1 扩)。这里给的是运维面:
2966    // 看段落目录(哪个 ns 有哪些键、哪些是引用)、修格式 rev、误配后抹段。
2967    crate::ui_ext::space_group(ui);
2968    crate::ui_ext::section_title(ui, "配置托底");
2969    ui.horizontal(|ui| {
2970        if ui.small_button(BTN_CFG_REFRESH).clicked() {
2971            shared.arm_cfg_delete(None);
2972            shared.request_cfg_refresh();
2973        }
2974        ui.label(
2975            egui::RichText::new(format!(
2976                "段落目录零值面(只要键名/形态;小键名 = 明面,(引用) = {} 形)",
2977                ek_cfg::REF_PREFIX
2978            ))
2979            .color(crate::tokens::semantic::INFO),
2980        );
2981    });
2982    match shared.cfg_briefs() {
2983        None => {
2984            ui.label(
2985                egui::RichText::new("(未问过段落目录;点『刷新段落目录』再画)")
2986                    .color(crate::tokens::semantic::MUTED),
2987            );
2988        }
2989        Some(briefs) if briefs.is_empty() => {
2990            ui.label(
2991                egui::RichText::new("(无段落;配置受理成功后会出现)")
2992                    .color(crate::tokens::semantic::MUTED),
2993            );
2994        }
2995        Some(briefs) => {
2996            let b_col_w = crate::ui_ext::grid_col_width(ui, "cfgb_grid", 4);
2997            let b_row_h = table_heights(ui).0;
2998            egui::Grid::new("cfgb_grid")
2999                .num_columns(4)
3000                .min_col_width(b_col_w)
3001                .min_row_height(b_row_h)
3002                .show(ui, |ui| {
3003                    for h in ["ns", "rev", "键", "操作"] {
3004                        ui.label(egui::RichText::new(h).strong().small());
3005                    }
3006                    ui.end_row();
3007                    for b in &briefs {
3008                        ui.label(b.ns.clone());
3009                        ui.label(b.rev.to_string());
3010                        ui.label(format!(
3011                            "{} 条:{}",
3012                            b.keys.len(),
3013                            b.keys
3014                                .iter()
3015                                .map(|k| if k.secret {
3016                                    format!("{}(引用)", k.key)
3017                                } else {
3018                                    k.key.clone()
3019                                })
3020                                .collect::<Vec<_>>()
3021                                .join("、"),
3022                        ));
3023                        ui.horizontal(|ui| {
3024                            let armed = shared.cfg_delete_armed();
3025                            let label = if armed.as_deref() == Some(b.ns.as_str()) {
3026                                format!("再次点击 清段 {}", b.ns)
3027                            } else {
3028                                format!("清段 {}", b.ns)
3029                            };
3030                            if ui.small_button(label).clicked() {
3031                                if armed.as_deref() == Some(b.ns.as_str()) {
3032                                    shared.arm_cfg_delete(None);
3033                                    shared.push_cmd(UiCmd::CfgDelete { ns: b.ns.clone() });
3034                                } else {
3035                                    shared.arm_cfg_delete(Some(b.ns.clone()));
3036                                }
3037                            }
3038                            if ui.small_button(format!("重存段 {}", b.ns)).clicked() {
3039                                shared.push_cmd(UiCmd::CfgResave { ns: b.ns.clone() });
3040                            }
3041                        });
3042                        ui.end_row();
3043                    }
3044                });
3045        }
3046    }
3047
3048    crate::ui_ext::space_group(ui);
3049    crate::ui_ext::section_title(ui, "服务");
3050    let flat: Vec<String> = snap
3051        .services
3052        .iter()
3053        .flat_map(|(n, ids)| {
3054            if ids.is_empty() {
3055                vec![format!("{n} 服务"), "-".to_string()]
3056            } else {
3057                ids.iter()
3058                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
3059                    .collect()
3060            }
3061        })
3062        .collect();
3063    // 表头(BD-UI-41,用户指出「配置那边表格也要有表头」):这张表原来六列纯数据没有头行,
3064    // 列语义 = 三对「服务名 / 实例 id」,头就照这个写。
3065    // 0.4.0 修正(用户实看:「运维…的表格也都要处理」):空态**不得吞掉表头**。原来空态只剩
3066    // 一句「(无服务目录)」,表头带与列宽全没了 —— 与资金格同一个病;口径 = 表头恒画。
3067    // (当时补的那句「空态是表体内的一行」到六期 BD-UI-59 撤除:空态 = 表头 + 0 行。)
3068    let mut svc_heads: Vec<(egui::Rect, String)> = Vec::new();
3069    let mut svc_rows: Vec<f32> = Vec::new();
3070    // 列宽下限同 plug_grid(BD-UI-60)。
3071    let svc_col_w = crate::ui_ext::grid_col_width(ui, "svc_grid", 6);
3072    let svc_row_h = table_heights(ui).0;
3073    egui::Grid::new("svc_grid")
3074        .num_columns(6)
3075        .min_col_width(svc_col_w)
3076        .min_row_height(svc_row_h)
3077        .show(ui, |ui| {
3078            for _ in 0..3 {
3079                for h in ["服务", "实例"] {
3080                    let r = ui.label(egui::RichText::new(h).strong().small());
3081                    svc_heads.push((r.rect, h.to_string()));
3082                }
3083            }
3084            ui.end_row();
3085            // 空态不再画提示行(0.4.1 六期 BD-UI-59):目录空 = 这张表只有表头那一行
3086            // (`rows = 0` → 循环不跑),表头带照画。
3087            let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
3088            for r in 0..rows {
3089                for c in 0..3 {
3090                    let i = c * rows * 2 + r * 2;
3091                    if i < flat.len() {
3092                        ui.label(flat[i].clone());
3093                        ui.label(flat[i + 1].clone());
3094                    } else {
3095                        ui.label("");
3096                        ui.label("");
3097                    }
3098                }
3099                svc_rows.push(ui.min_rect().max.y);
3100                ui.end_row();
3101            }
3102        });
3103    let svc_band = header_band(ui, &svc_heads, ui.min_rect().max.x);
3104    grid_chrome(ui, svc_band, &svc_heads, &svc_rows);
3105
3106    ui.label("依赖");
3107    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
3108        ui.label("(全部无依赖)");
3109    }
3110    for r in &snap.rows {
3111        if !r.deps.is_empty() {
3112            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
3113        }
3114    }
3115}
3116
3117/// 账户面板(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
3118/// 列表上方 = 行情源一览 + 账户计数(软上限)+ 『添加账户』入口;
3119/// 列表 = **跨账户一览**(状态 / 行情源 / 收录勾选与网关实际收录 / 行操作);
3120/// 配置表单**移入独立弹窗**(`render_account_edit_window`)—— 本窗不再内嵌表单,
3121/// 旧「窗内一张共享表单」退役(消 U4 凭据串味 / U6 取消编辑不清空的界面根)。
3122fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
3123    let accounts = shared.accounts();
3124    let locked = shared.accounts_locked();
3125    let md = shared.md_source();
3126    let snap = shared.trade_snap();
3127
3128    // 行内显示名(别名优先,空回退账号;重名回退派生名)。
3129    // 同一行里『编辑 X』与『移除 X』必须用同一个 X(规划 U10 口径齐),
3130    // 而寻址恒用派生名 —— 别名只是显示,不参与寻址(规划 Q3)。
3131    let row_label: std::collections::BTreeMap<String, String> = accounts
3132        .iter()
3133        .map(|a| {
3134            let d = a.display_name();
3135            let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
3136            (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
3137        })
3138        .collect();
3139
3140    // 列表上方:行情源一览(订阅一律走它,与账户增删无关)+ 计数 + 添加入口。
3141    ui.horizontal(|ui| {
3142        ui.label(
3143            egui::RichText::new(format!(
3144                "当前行情源 = {}",
3145                if md.is_empty() {
3146                    "(未指定)".to_string()
3147                } else {
3148                    md.clone()
3149                }
3150            ))
3151            .strong(),
3152        );
3153        ui.label(format!(
3154            "账户 {}/{}",
3155            accounts.len(),
3156            crate::state::MAX_ACCOUNTS
3157        ));
3158        if shared.can_add_account() {
3159            if ui.button(BTN_ACCOUNT_ADD).clicked() {
3160                shared.begin_account_edit(None);
3161            }
3162        } else {
3163            ui.label(
3164                egui::RichText::new(format!(
3165                    "账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
3166                    crate::state::MAX_ACCOUNTS
3167                ))
3168                .color(crate::tokens::semantic::WARN),
3169            );
3170        }
3171    });
3172
3173    const ACCOUNT_HEADERS: [&str; 8] = [
3174        "网关(派生名)",
3175        "别名",
3176        "账号",
3177        "状态",
3178        "阶段",
3179        "行情源",
3180        "收录(勾→实)",
3181        "操作",
3182    ];
3183    // 0.2.17 布局修复:账户清单不用 pinned_table。pinned_table = ScrollArea
3184    // (auto_shrink=false,吃掉整块可用高)+ 覆盖式表头带;账户窗是**自动尺寸浮动窗**,
3185    // 两者凑不出正确高度 → 表行与其后的口径句**互相压字**(快照钉 s39 实锤)。
3186    // 账户行最多个位数,普通 Grid 就够,不需要固定表头滚动面 —— 但表头带照补
3187    // (BD-UI-41:走 `header_band`,它正是为「自动尺寸浮动窗里的短表」准备的)。
3188    let mut acct_heads: Vec<(egui::Rect, String)> = Vec::new();
3189    let mut acct_rows: Vec<f32> = Vec::new();
3190    // 列宽下限同 plug_grid(BD-UI-60)。
3191    let acct_col_w = crate::ui_ext::grid_col_width(ui, "account_grid", 8);
3192    let acct_row_h = table_heights(ui).0;
3193    egui::Grid::new("account_grid")
3194        .num_columns(8)
3195        .min_col_width(acct_col_w)
3196        .min_row_height(acct_row_h)
3197        .show(ui, |ui| {
3198            for h in &ACCOUNT_HEADERS {
3199                let r = ui.label(egui::RichText::new(*h).strong().small());
3200                acct_heads.push((r.rect, h.to_string()));
3201            }
3202            ui.end_row();
3203        // 空态不再画提示行(0.4.1 六期 BD-UI-59):清册空 = 这张表只有表头那一行。
3204        for a in &accounts {
3205            let is_md = a.gateway == md;
3206            let name = row_label
3207                .get(&a.gateway)
3208                .cloned()
3209                .unwrap_or_else(|| a.gateway.clone());
3210            ui.label(a.gateway.clone());
3211            // 别名(空 = 显示 "—";寻址恒用派生名,别名不参与,规划 Q3)。
3212            ui.label(if a.alias.trim().is_empty() {
3213                "—".to_string()
3214            } else {
3215                a.alias.clone()
3216            });
3217            ui.label(a.account.clone());
3218            // 状态列(0.2.19 起两段):装配态(svctl 在场位)+ **登录态**(`{gw}.status` 读数)。
3219            // 登录成败不再靠猜:ek-ctp 0.9.0 的 `{gw}.status` 是事实源(规划 P3 / 裁定 3),
3220            // 没有读数时仍写「已装配·登录态未知」,不得写成「已连接」(规划 U5 口径不变)。
3221            // 文案一律 `Extend`(不换行):账户面板是 Grid,状态列被挤窄时逐字竖排
3222            // 会把一行撑成十几行高(s39 快照实锤过)。
3223            match a.stage {
3224                crate::state::AccountStage::AssembleFailed => {
3225                    ui.add(
3226                        egui::Label::new(
3227                            egui::RichText::new(format!(
3228                                "装配失败:{}",
3229                                a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
3230                            ))
3231                            .color(crate::tokens::semantic::ERROR),
3232                        )
3233                        .wrap_mode(egui::TextWrapMode::Extend),
3234                    );
3235                }
3236                _ => {
3237                    let color = match a.login {
3238                        crate::state::LoginState::Online => crate::tokens::semantic::OK,
3239                        crate::state::LoginState::LoginFailed
3240                        | crate::state::LoginState::FrontDown => {
3241                            crate::tokens::semantic::ERROR
3242                        }
3243                        crate::state::LoginState::QueryStarved => {
3244                            crate::tokens::semantic::WARN
3245                        }
3246                        // Unknown / Stopped:灰 —— 没有证据与主动停会话都不该着色成"出事"
3247                        _ => crate::tokens::semantic::MUTED,
3248                    };
3249                    // 0.4.1 十期 BD-UI-63(用户裁定):登录被拒时,**柜台原因直接跟着红字打在账户行上**
3250                    // (实连实锤:220380 在 7×24 套被柜台拒「不合法的登录(3)」,而账户行只写
3251                    // 「已装配·登录失败」—— 用户只看到"登不上",看不到"为什么登不上")。
3252                    // 只跟短标签(整句在窗下方「状态细节」那一行给全):Grid 状态列被长句挤宽
3253                    // 会把别的列顶窄(s39 口径)。
3254                    let text = match (a.login.is_alarm(), a.login_brief.as_ref()) {
3255                        (true, Some(brief)) => format!("{}·{}", a.status_label(), brief),
3256                        _ => a.status_label(),
3257                    };
3258                    ui.add(
3259                        egui::Label::new(egui::RichText::new(text).color(color))
3260                            .wrap_mode(egui::TextWrapMode::Extend),
3261                    );
3262                }
3263            }
3264            // 阶段列(0.5.0,用户定「链进行的状态改为事件日志推送」):该网关最近一条 `[链] `
3265            // 状态行的正文(ek-ctp 0.9.8 推的链状态)。`None` = 这一条会话还没推过,或实例已不在场
3266            // (`sync_accounts_connected` 一并作废)→ 「—」。
3267            // 它只是显示:不参与状态列那套在线/断供判据。与状态列一样 `Extend`——Grid 里
3268            // 被挤窄会逐字竖排(s39 实锤)。
3269            ui.add(
3270                egui::Label::new(
3271                    egui::RichText::new(a.chain_note.clone().unwrap_or_else(|| "—".to_string()))
3272                        .color(crate::tokens::semantic::MUTED)
3273                        .small(),
3274                )
3275                .wrap_mode(egui::TextWrapMode::Extend),
3276            );
3277            // 行情元账号:配置阶段任选;任何账户已装配即锁定(规划 D3)。
3278            ui.horizontal(|ui| {
3279                if is_md {
3280                    ui.label(egui::RichText::new("● 行情源").strong());
3281                }
3282                let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
3283                    ui.small_button(if is_md {
3284                        "已是行情源"
3285                    } else {
3286                        "设为行情源"
3287                    })
3288                });
3289                if resp.inner.clicked() {
3290                    match shared.set_md_source(&a.gateway) {
3291                        Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
3292                        Err(e) => shared.log_line(format!("[账户] {e}")),
3293                    }
3294                }
3295            });
3296            // 收录:勾选(意图)→ 网关实际回带(事实),并排显示(规划 U9)。
3297            // 旧口径:`ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
3298            ui.vertical(|ui| {
3299                ui.add(
3300                    egui::Label::new(egui::RichText::new(format!(
3301                        "勾:{}",
3302                        classes_label(&a.form.product_classes())
3303                    )))
3304                    .wrap_mode(egui::TextWrapMode::Extend),
3305                );
3306                ui.add(
3307                    egui::Label::new(egui::RichText::new(format!(
3308                        "实:{}",
3309                        if a.effective_classes.is_empty() {
3310                            "—".to_string()
3311                        } else {
3312                            classes_label(&a.effective_classes)
3313                        }
3314                    )))
3315                    .wrap_mode(egui::TextWrapMode::Extend),
3316                );
3317            });
3318            // 操作:连接/重试(四态分离的『连接』入口)/ 断开(0.2.19 停会话)/ 编辑(开配置弹窗)/ 移除(守卫 + 先撤入口)。
3319            ui.horizontal(|ui| {
3320                let failed = a.stage == crate::state::AccountStage::AssembleFailed;
3321                let idle = a.stage == crate::state::AccountStage::NotAssembled;
3322                // 会话被主动停掉(『断开』)但实例还在:恢复入口仍是这一颗『连接』
3323                // (走 `UiCmd::Connect` → svctl 回「已在场」→ 插件侧改走重投凭据,规划裁定 1)。
3324                let stopped = a.login == crate::state::LoginState::Stopped;
3325                if failed || idle || stopped {
3326                    // 规划 U3 定盘:装配失败**不回滚清册行**,行留着给『重试』
3327                    // (旧口径"必须先移除才能重试"作废)。
3328                    let label = if failed {
3329                        format!("重试 {name}")
3330                    } else {
3331                        format!("连接 {name}")
3332                    };
3333                    if ui.small_button(label).clicked() {
3334                        push_connect(shared, &a.gateway, &a.form);
3335                    }
3336                }
3337                // 该账户的活跃委托数(『断开』与『移除』共用同一道守卫,见下)。
3338                let open = snap
3339                    .orders
3340                    .iter()
3341                    .filter(|o| o.gateway_name == a.gateway && o.is_active())
3342                    .count();
3343                // 『断开』(0.2.19,规划裁定 1「断开 = 停会话,移除 = 停实例」):
3344                // 只对**已装配、会话还活着、且没有活跃委托**的行开。
3345                // - 未装配没有会话可停(规划 Q21:口不存在时不留死钮;现在口已实现,钮才允许出现);
3346                // - 有活跃委托时不给断开 —— 会话一停就撤不了单、也不再回报,与『移除』
3347                //   要挡的是同一个观测黑洞(规划 §5.B),出口同样是旁边那句『先撤 N 笔』。
3348                // `&&` 短路即"条件不成立就不画这颗钮",不是画出来再禁用。
3349                if a.stage == crate::state::AccountStage::Assembled
3350                    && a.login != crate::state::LoginState::Stopped
3351                    && open == 0
3352                    && ui.small_button(format!("断开 {name}")).clicked()
3353                {
3354                    shared.push_cmd(UiCmd::Disconnect {
3355                        gateway: a.gateway.clone(),
3356                    });
3357                    shared.log_line(format!(
3358                        "[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
3359                        gateway = a.gateway
3360                    ));
3361                }
3362                if ui.small_button(format!("编辑 {name}")).clicked() {
3363                    // 编辑 = 开配置弹窗(草稿 = 该账户自己的留档)。
3364                    // 旧的 `select_row("account_grid", …)` 行选记槽已无读者(内嵌表单时代遗留)→ 撤除。
3365                    shared.begin_account_edit(Some(&a.gateway));
3366                }
3367                let has_pos = snap
3368                    .positions
3369                    .iter()
3370                    .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
3371                if open > 0 {
3372                    // 守卫硬挡:柜台还在跑单而没人能撤 = 观测黑洞(规划 §5.B)。
3373                    ui.label(
3374                        egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
3375                            .color(crate::tokens::semantic::WARN),
3376                    );
3377                    // 规划 U10:守卫不能只把用户堵在门口,要给出口 ——
3378                    // 『先撤 N 笔』按该账户的活跃委托逐笔入列(与「全撤」同一条 oms 通道,
3379                    // 走每笔单自己的网关,撞撤单计数守卫时显形 limit:cancel-guard)。
3380                    if ui.small_button(format!("先撤 {open} 笔")).clicked() {
3381                        for o in snap
3382                            .orders
3383                            .iter()
3384                            .filter(|o| o.gateway_name == a.gateway && o.is_active())
3385                        {
3386                            shared.push_cmd(UiCmd::CancelOrder {
3387                                orderid: o.orderid.clone(),
3388                                gateway: a.gateway.clone(),
3389                            });
3390                        }
3391                        shared.log_line(format!(
3392                            "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
3393                            a.gateway, open
3394                        ));
3395                    }
3396                } else {
3397                    // 二次确认的记槽按**派生名**(寻址主键),按钮文案用显示名 ——
3398                    // 两者必须在这里对上,否则首点后按钮永远翻不到『确认移除』(Commit B 自查抓到的坑)。
3399                    let armed = shared.pending_confirm();
3400                    let armed_here = armed.as_deref() == Some(a.gateway.as_str());
3401                    let fired = ui
3402                        .small_button(remove_button_label(
3403                            &name,
3404                            if armed_here { Some(name.as_str()) } else { None },
3405                        ))
3406                        .clicked()
3407                        && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
3408                    if fired {
3409                        shared.push_cmd(UiCmd::Remove {
3410                            name: a.gateway.clone(),
3411                        });
3412                        shared.remove_account(&a.gateway);
3413                        // 弹窗是非 modal:若这行正在被编辑,摘除必须连带关弹窗放弃,
3414                        // 否则弹窗会静默退化成「添加模式」,点『保存』会拿旧凭据建新账户。
3415                        shared.end_account_edit_if_editing(&a.gateway);
3416                        if has_pos {
3417                            shared.log_line(format!(
3418                                "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
3419                                a.gateway
3420                            ));
3421                        }
3422                    }
3423                }
3424            });
3425            acct_rows.push(ui.min_rect().max.y);
3426            ui.end_row();
3427        }
3428    });
3429    let acct_band = header_band(ui, &acct_heads, ui.min_rect().max.x);
3430    grid_chrome(ui, acct_band, &acct_heads, &acct_rows);
3431
3432    if let Some(gw) = shared.pending_confirm() {
3433        // 提示句与行内钮同一显示名(行里写『移除 220380』,提示就不能写『移除 ctp_9999_220380』)。
3434        let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
3435        ui.horizontal(|ui| {
3436            ui.label(
3437                egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
3438                    .color(crate::tokens::semantic::WARN),
3439            );
3440            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
3441                shared.confirm_cancel();
3442            }
3443        });
3444    }
3445
3446    ui.label(if locked {
3447        "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
3448    } else {
3449        "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
3450    });
3451    // 状态口径统一说明(面板底部一句,不逐行重复 —— 逐行会把状态列撑成竖排):
3452    ui.label(
3453        egui::RichText::new(format!(
3454            "状态列 = 实例装配 + 网关状态口 {{gw}}.status(每拍最多问 {} 个账户,环形轮询);阶段列 = 网关推送的 `[链] ` 链状态行(结算单确认 → 查合约 → 资金/持仓轮询走到哪一步);『断开』= 停会话(实例与订阅保留),『移除』= 停实例",
3455            crate::state::STATUS_ASKS_PER_TICK
3456        ))
3457        .color(crate::tokens::semantic::INFO),
3458    );
3459    // F4 口径显形(0.2.20):多账户下「谁查谁过滤」此前只能靠猜 —— 实连 S14b 里
3460    // 账户 B 把勾选改成「期货+期权」,公用合约表仍是 852 条、期权 0 条,用户没有任何
3461    // 办法判断是自己没生效、还是网关没查到。这里把口径写死成一句(不靠界面猜):
3462    // 勾选只管**这个网关自己**收录并上抛的部分,公用表是在场各网关的并集。
3463    ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).color(crate::tokens::semantic::INFO));
3464    // 状态细节(0.2.19):凡是有话可说的行都给一句(带次数、带秒数、带原因)。
3465    // 为什么不在状态列里带:Grid 列宽实测会被长文案挤成竖排(s39 实锤)。
3466    // 为什么不只给告警行:「状态口问不到」与「纯行情账户」也是事实 ——
3467    // 灰色徽标不给原因,用户就只能猜「是没问到还是没登录」。
3468    // 只给已装配的行:会话读数属于那个实例,行都「未装配」了还印「掉线 2 次」
3469    // 就是在替一个不存在的会话说话(状态侧由 `sync_accounts_connected` 一并作废,
3470    // 这里是不对称时刻——快照还没刷——的第二道闸)。
3471    // 健康行没有细节(`count_note(…, 0) = None`),所以这一段不会随账户数无谓变长。
3472    for a in accounts
3473        .iter()
3474        .filter(|a| a.stage == crate::state::AccountStage::Assembled && a.status_note.is_some())
3475    {
3476        let note = a.status_note.clone().unwrap_or_default();
3477        let alarm = a.login.is_alarm() || a.login == crate::state::LoginState::QueryStarved;
3478        let color = if alarm {
3479            crate::tokens::semantic::ERROR
3480        } else {
3481            crate::tokens::semantic::INFO
3482        };
3483        ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
3484        // 0.3.2(Wave E,BD-UI-50):告警 toast **不在这里产生**。产生口只有一处
3485        // = `UiShared::note_gateway_status`(轮询一拍),判据 e6 钉着这条归属。
3486        // 在这里弹 = 账户窗没开就没有告警,而 Python 的 AlertEngine 与哪个窗在不在无关。
3487    }
3488    // 元账号 md 前置为空 = 永远收不到 tick(添加时被单源纪律清空,须编辑页补填)。
3489    if let Some(src) = accounts.iter().find(|a| a.gateway == md)
3490        && src.form.md_addr.trim().is_empty()
3491    {
3492        ui.label(
3493            egui::RichText::new(format!(
3494                "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情不会出数",
3495                src.gateway,
3496                row_label
3497                    .get(&src.gateway)
3498                    .cloned()
3499                    .unwrap_or_else(|| src.account.clone())
3500            ))
3501            .color(crate::tokens::semantic::WARN),
3502        );
3503    }
3504}
3505
3506/// 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):『添加账户』与
3507/// 『编辑 <账户>』开**同一个弹窗的两种模式**;非 modal(不阻塞主窗与行情观察)。
3508/// 账号/前置全部界面手工输入,插件不读文件;密码位掩码;**无订阅字段** ——
3509/// 用户裁定:订阅走下单面板代码回车或查询合约窗勾选。
3510/// 关闭(『取消』或窗 ×)= **放弃未保存**(规划 Q24);被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
3511/// 账户表单的标签列(0.4.1 九期 BD-UI-62,用户裁定「这几个字段全部放成一列,数值也放成一列,
3512/// 每一个字段一行」):标签占固定宽,数值列因此对齐成一列。
3513///
3514/// 为什么不用 `Grid`:0.2.17 实锤(s39 同一段)—— 表单 Grid 的列宽在测量期被「窗宽 / 列数」
3515/// 夹死(实测每格 ≈120px),`desired_width` 永不生效,一根真实前置 `tcp://182.254.242.81:30001`
3516/// 只显示出 "tcp:/"。固定宽的标签格 + 同一行剩下的宽度给数值,两头都不夹。
3517const ACCT_FORM_LABEL_W: f32 = 86.0;
3518
3519fn acct_form_label(ui: &mut egui::Ui, text: &str) {
3520    ui.scope_builder(
3521        egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
3522            ui.cursor().min,
3523            egui::vec2(
3524                ACCT_FORM_LABEL_W,
3525                ui.text_style_height(&egui::TextStyle::Body),
3526            ),
3527        )),
3528        |ui| {
3529            ui.label(text);
3530        },
3531    );
3532}
3533
3534fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
3535    let editing: Option<crate::state::AccountRow> =
3536        shared.editing_account().and_then(|g| shared.account(&g));
3537    let editing_on = editing.is_some();
3538    let md = shared.md_source();
3539    let locked = shared.accounts_locked();
3540    let mut form: crate::state::CtpForm = shared.ctp_form();
3541    let mut alias = shared.edit_alias();
3542    // 被拦原因上屏(形制校验 / 清册守卫 / 装配被拒):弹窗不关(规划 U3 / Q26)。
3543    if let Some(e) = shared.edit_error() {
3544        ui.label(egui::RichText::new(format!("拦下:{e}")).color(crate::tokens::semantic::ERROR));
3545    }
3546    // 0.2.17 布局口径:表单用 horizontal 行不用 Grid —— Grid 列宽在测量期被
3547    // 「窗宽 / 列数」夹死(实测每格 ≈120px),`desired_width` 永不生效,
3548    // 一根真实前置 `tcp://182.254.242.81:30001`(30 字符)只显示出 "tcp:/"。
3549    // 身份行:别名(仅显示用,不参与寻址,规划 Q3)+ broker + td 前置 + td 用户。
3550    // 编辑模式 broker / td 用户**只读**(规划 Q25:它们决定派生注册名,就地改 = 孤儿实例 + 账本错配)。
3551    // 0.4.1 九期 BD-UI-62:一字段一行(原来四个字段挤在一行,窄窗里数值列互相挤)。
3552    ui.horizontal(|ui| {
3553        acct_form_label(ui, "别名");
3554        if ui
3555            .add(
3556                egui::TextEdit::singleline(&mut alias)
3557                    .hint_text("显示用,可空")
3558                    .desired_width(120.0),
3559            )
3560            .changed()
3561        {
3562            shared.set_edit_alias(alias.clone());
3563        }
3564    });
3565    ui.horizontal(|ui| {
3566        acct_form_label(ui, "broker");
3567        ui.add_enabled_ui(!editing_on, |ui| {
3568            ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
3569        });
3570    });
3571    ui.horizontal(|ui| {
3572        acct_form_label(ui, "td 前置");
3573        ctp_edit(
3574            ui,
3575            shared,
3576            &mut form.td_addr,
3577            "tcp://host:40001",
3578            CtpField::TdAddr,
3579        );
3580    });
3581    ui.horizontal(|ui| {
3582        acct_form_label(ui, "td 用户");
3583        ui.add_enabled_ui(!editing_on, |ui| {
3584            ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
3585        });
3586    });
3587    if let Some(r) = &editing {
3588        // 只读口径说明(规划 Q25):派生注册名由 broker + 账号决定,就地改会留下
3589        // 跑着旧名的孤儿实例(svctl 侧无改名口),账本里的归属也会错配。
3590        ui.label(
3591            egui::RichText::new(format!(
3592                "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
3593                r.gateway
3594            ))
3595            .color(crate::tokens::semantic::WARN),
3596        );
3597    }
3598    ui.horizontal(|ui| {
3599        acct_form_label(ui, "td 密码");
3600        ctp_edit(
3601            ui,
3602            shared,
3603            &mut form.td_password,
3604            "密码",
3605            CtpField::TdPassword,
3606        );
3607    });
3608    ui.horizontal(|ui| {
3609        acct_form_label(ui, "AppID");
3610        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
3611    });
3612    ui.horizontal(|ui| {
3613        acct_form_label(ui, "AuthCode");
3614        ctp_edit(
3615            ui,
3616            shared,
3617            &mut form.td_auth_code,
3618            "授权码",
3619            CtpField::AuthCode,
3620        );
3621    });
3622    // 单源纪律(规划 Q12):编辑模式下「非行情源账户 + 行情源已锁定」→ md 位置灰。
3623    // 它当不了源,填了也会在装配侧被清空;要换源须先移除在场账户(规划 D3)。
3624    let md_editable = match &editing {
3625        None => true,
3626        Some(r) => !locked || r.gateway == md,
3627    };
3628    // md 账号 / 密码**不再单列**(0.4.1 九期 BD-UI-62,用户裁定「MD 的那个账号和密码不要单独列了,
3629    // 默认的就是不用登录嘛,我们就不用填」)。口径与 Python 一致:Python 侧 CTP 配置只有
3630    // 一套账号/密码(`trader/utility.py:339` 一带,`appID` 那一格),行情会话复用同一套凭据 ——
3631    // 全仓 grep 不到任何 `md_user` / `md_password`。我们的 `CtpForm.md_user/md_password` 因此
3632    // 退回成「装配侧内部字段」:表单不给填,留空 = 回落 td 凭据(与 Python 同一行为)。
3633    ui.horizontal(|ui| {
3634        acct_form_label(ui, "md 前置");
3635        ui.add_enabled_ui(md_editable, |ui| {
3636            ctp_edit(
3637                ui,
3638                shared,
3639                &mut form.md_addr,
3640                "tcp://host:40011",
3641                CtpField::MdAddr,
3642            );
3643        });
3644    });
3645    if !md_editable {
3646        ui.horizontal(|ui| {
3647            acct_form_label(ui, "");
3648            ui.label(
3649                egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
3650                    .color(crate::tokens::semantic::WARN),
3651            );
3652        });
3653    }
3654    // 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。勾选变更立刻回写共享表单
3655    // —— 与 ctp_edit 同一口径,否则下一帧从 shared 重读就把这次点击丢了。
3656    ui.horizontal(|ui| {
3657        ui.label("收录勾选:");
3658        let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
3659        let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
3660        if fut || opt {
3661            shared.set_ctp_form(form.clone());
3662        }
3663        ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
3664    });
3665    // F4 口径(0.2.20,与账户面板底部同一句 —— 共用 `TXT_PRODUCT_CLASS_SCOPE` 常量)。
3666    // 公用合约表是在场各网关的并集:从账户改了勾选、行情源账户没改,表就不会多一行期权。
3667    ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).weak());
3668    // 只读回带区(规划 U9):勾选是**意图**,网关回带才是**事实** ——
3669    // 旧口径 `ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
3670    if let Some(r) = &editing {
3671        ui.horizontal(|ui| {
3672            ui.label("网关实际收录(只读回带):");
3673            ui.label(if r.effective_classes.is_empty() {
3674                "—(尚未装配或无回带)".to_string()
3675            } else {
3676                classes_label(&r.effective_classes)
3677            });
3678            ui.label("装配/登录状态(只读):");
3679            ui.label(format!(
3680                "{}{}",
3681                r.status_label(),
3682                r.fail_note
3683                    .as_ref()
3684                    .map(|n| format!(" —— {n}"))
3685                    .unwrap_or_default()
3686            ));
3687        });
3688    }
3689    // 操作行(规划 §3.3 四态):『保存』只写清册;『保存并连接』= 保存 + 入列一条 Connect 令;
3690    // 『取消』= 放弃未保存(与窗 × 同口径,规划 Q24)。被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
3691    if shared.account_edit_dirty() {
3692        ui.label(
3693            egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
3694                .color(crate::tokens::semantic::WARN),
3695        );
3696    }
3697    ui.horizontal(|ui| {
3698        if ui.button("保存").clicked() {
3699            match save_account_draft(shared, &form, &alias, false) {
3700                Ok(()) => shared.end_account_edit(),
3701                Err(e) => {
3702                    shared.log_line(format!("[账户] 保存被拦:{e}"));
3703                    shared.set_edit_error(Some(e));
3704                }
3705            }
3706        }
3707        if ui.button("保存并连接").clicked() {
3708            match save_account_draft(shared, &form, &alias, true) {
3709                Ok(()) => shared.end_account_edit(),
3710                Err(e) => {
3711                    shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
3712                    shared.set_edit_error(Some(e));
3713                }
3714            }
3715        }
3716        if ui.button("取消").clicked() {
3717            shared.end_account_edit();
3718        }
3719        ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
3720    });
3721}
3722
3723/// 账户清册里的派生名集合(账本标注用,每表算一次,不在每格里克隆清册)。
3724fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
3725    shared
3726        .accounts()
3727        .iter()
3728        .map(|a| a.gateway.clone())
3729        .collect()
3730}
3731
3732/// 账本行的网关标注(0.2.18 Commit B,规划 U8):该网关已不在账户清册 = **已摘除账户的残留行**。
3733///
3734/// 摘除不清账本(柜台侧不受影响,重加该账户后由轮转查询重建视图 —— F5 实连复测:
3735/// 摘除后账本 81/29/44/2 原封不动),但界面必须说清这一行属于谁:旧口径零标注,
3736/// 用户会把已摘除账户的委托当成在场账户的。
3737/// 口径边界:**清册为空 = 不标注**(没有账户配置面时谈不上「摘除」,纯账本巡回钉 s27 即此形);
3738/// 资金格不标注(那里的网关位是账户来源标签,`ctp` 这类历史字面量不在清册口径内 —— 刻意划界)。
3739fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
3740    if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
3741        gateway.to_string()
3742    } else {
3743        format!("{gateway}(已摘除)")
3744    }
3745}
3746
3747/// 产品类短标签(`"1"` → 期货,`"2"` → 期权):勾选意图与网关回带事实并排显示用。
3748fn classes_label(classes: &[String]) -> String {
3749    if classes.is_empty() {
3750        return "—".to_string();
3751    }
3752    classes
3753        .iter()
3754        .map(|c| match c.as_str() {
3755            "1" => "期货",
3756            "2" => "期权",
3757            other => other,
3758        })
3759        .collect::<Vec<_>>()
3760        .join("+")
3761}
3762
3763/// 『连接』入口的唯一出令处(四态分离:一条令做完 `svctl.start` + `{gw}.configure`)。
3764///
3765/// 入列同时就地置「装配中」:插件出队时也会置(幂等),但**入列到出队之间**若不置,
3766/// 行上的『连接/重试』钮还亮着 = 可重复入列(规划 Q7 防重入的界面侧一半)。
3767fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
3768    shared.push_cmd(UiCmd::Connect {
3769        gateway: gateway.to_string(),
3770        form: form.clone(),
3771    });
3772    shared.note_connect_sent(gateway);
3773    shared.log_line(format!(
3774        "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
3775        gateway
3776    ));
3777}
3778
3779/// 配置弹窗『保存』/『保存并连接』的共同路径:**校验 → 写清册 →(可选)入列一条 Connect 令**。
3780///
3781/// - 编辑模式:覆盖该账户留档 + 别名(broker/账号只读 → 派生名不变,规划 Q25)。
3782/// - 添加模式:派生建行(别名随行入册,唯一性在 `add_account` 守卫里)+ 单源定源(规划 §5.C / D3)。
3783///
3784/// 被拦返回原因字符串:调用方**不关弹窗**,把原因写进 `edit_error` 显形(规划 Q26)。
3785fn save_account_draft(
3786    shared: &UiShared,
3787    form: &crate::state::CtpForm,
3788    alias: &str,
3789    connect: bool,
3790) -> Result<(), String> {
3791    form.validate()?;
3792    if let Some(gw) = shared.editing_account() {
3793        // 编辑目标必须还在清册(弹窗非 modal:期间该行可能被摘除)。
3794        if shared.account(&gw).is_none() {
3795            return Err(format!(
3796                "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
3797            ));
3798        }
3799        shared.update_account_form(&gw, form.clone());
3800        if !shared.set_account_alias(&gw, alias) {
3801            return Err(format!(
3802                "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
3803            ));
3804        }
3805        shared.log_line(format!(
3806            "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
3807        ));
3808        if connect {
3809            push_connect(shared, &gw, form);
3810        }
3811        return Ok(());
3812    }
3813    let mut row = crate::state::AccountRow::derive(form)?;
3814    row.alias = alias.trim().to_string();
3815    let gateway = row.gateway.clone();
3816    let account = row.account.clone();
3817    shared.add_account(row.clone())?;
3818    // 单源纪律在「保存」这一刻定源(规划 §5.C / D3):无源且无账户在场 → 新账户就是元账号
3819    // (md 前置保留 —— 清掉它等于这个账户自己也永远收不到 tick);
3820    // 已有源或已有账户在场 → 按纯 TD 装配(md 位清空,**留档与下发同一份**,消 U4)。
3821    let md = shared.md_source();
3822    let mut f = row.form.clone();
3823    if md.is_empty() && !shared.accounts_locked() {
3824        match shared.set_md_source(&gateway) {
3825            Ok(()) => {
3826                if f.md_addr.trim().is_empty() {
3827                    shared.log_line(format!(
3828                        "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
3829                    ));
3830                } else {
3831                    shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
3832                }
3833            }
3834            Err(e) => {
3835                shared.log_line(format!("[账户] {e}"));
3836                f.md_addr.clear();
3837                f.md_user.clear();
3838                f.md_password.clear();
3839            }
3840        }
3841    } else {
3842        f.md_addr.clear();
3843        f.md_user.clear();
3844        f.md_password.clear();
3845        if md.is_empty() {
3846            shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
3847        }
3848    }
3849    shared.update_account_form(&gateway, f.clone());
3850    // **四态分离:『保存』到此为止** —— 不 start、不 configure(规划裁定)。
3851    shared.log_line(format!(
3852        "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
3853    ));
3854    if connect {
3855        push_connect(shared, &gateway, &f);
3856    }
3857    Ok(())
3858}
3859
3860/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
3861/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
3862/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
3863fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
3864    let mut q = shared.contract_query();
3865    ui.horizontal(|ui| {
3866        ui.label("代码或交易所(留空 = 全部):");
3867        if ui
3868            .add(
3869                egui::TextEdit::singleline(&mut q)
3870                    .hint_text("cu 或 SHFE")
3871                    .desired_width(200.0),
3872            )
3873            .changed()
3874        {
3875            shared.set_contract_query(q.clone());
3876        }
3877    });
3878    let rows = shared.contracts(&q);
3879    // 表头单源(0.2.6 审计修:空态分支曾误写另一套表头「代码/…/最后价」,
3880    // 与非空分支「选(代码)/…/最小变动」不一致——列语义两处必须同源)。
3881    const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
3882    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59,同未成交口径)。
3883    // 0.2.29 那条审计修仍然成立:空态与非空态必须走同一条路(同一 id_salt、同一列语义),
3884    // 原来空态分支走无列线的入口、非空走有列线的入口,同一张表在空/非空之间切换时形制会变。
3885    let selected = shared.selected_contracts();
3886    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
3887    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
3888    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
3889    // 行溢出才出滚动条。
3890    ui.horizontal(|ui| {
3891        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
3892        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
3893            if selected.is_empty() {
3894                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
3895            } else {
3896                // en_symbol 勾选集 → CTP 裸代码订阅(ask {gw}.subscribe 收裸代码)
3897                let symbols: Vec<String> = selected
3898                    .iter()
3899                    .map(|k| {
3900                        k.split_once('.')
3901                            .map(|(_, s)| s.to_string())
3902                            .unwrap_or_else(|| k.clone())
3903                    })
3904                    .collect();
3905                match subscribe_target(shared) {
3906                    Ok(gw) => {
3907                        shared.push_cmd(UiCmd::CtpSubscribe {
3908                            gateway: gw.clone(),
3909                            symbols,
3910                        });
3911                        shared.log_line(format!(
3912                            "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
3913                            selected.len(),
3914                            gw
3915                        ));
3916                    }
3917                    Err(e) => shared.log_line(format!("[订阅] {e}")),
3918                }
3919            }
3920        }
3921    });
3922    // 网格线:这张表曾经是「全仓唯一开网格线的表」(Python 侧只有合约表没调
3923    // `setShowGrid(False)`:widget.py:2423 / 2621 建表时没关,交易表在 367 关了)。
3924    // 0.4.1 五期 BD-UI-58 按用户裁定反过来:网格线是**全部表**的固定形制,这条 Python
3925    // 差异从此不再成立(记死在 CHANGELOG / DESIGN / README)。
3926    pinned_table(ui, "contract_grid", &CONTRACT_HEADERS, |body, row_h| {
3927        const RIGHT_COLS: [usize; 2] = [3, 4];
3928        for c in &rows {
3929            let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
3930            let on = selected.contains(&key);
3931            // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
3932            // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
3933            // 行选中(动作归行,不归某一列)。
3934            let row_sel = shared.row_selected("contracts", &key);
3935            // key/on 都是行内局部:格延迟画笔要活到 show,必须自带所有权 ——
3936            // 在 rep_row 闭包外准备 key 克隆与 on 拷贝,各格自己再取。
3937            let key_owned = key.clone();
3938            let on_owned = on;
3939            body.row(row_h, row_sel, |r| {
3940                macro_rules! cell {
3941                    ($make:expr, $col:expr) => {{
3942                        let key = key_owned.clone();
3943                        r.cell(move |ui, ctx| {
3944                            let (cell_r, inner_r) =
3945                                pin_cell(ui, ctx, RIGHT_COLS.contains(&$col), $make);
3946                            let hit =
3947                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
3948                            if hit {
3949                                shared.select_row("contracts", &key.clone());
3950                            }
3951                        });
3952                    }};
3953                }
3954                // 勾选框(首格):`changed()` 在勾选框自己的 Response 上,动作就地取。
3955                {
3956                    let key = key_owned.clone();
3957                    let mut on = on_owned;
3958                    r.cell(move |ui, _ctx| {
3959                        if ui.checkbox(&mut on, key.clone()).changed() {
3960                            shared.toggle_contract_selection(&key);
3961                            shared.select_row("contracts", &key);
3962                        }
3963                    });
3964                }
3965                cell!(|ui| ui.label(c.exchange.as_str().to_string()), 1);
3966                cell!(|ui| ui.label(c.name.clone()), 2);
3967                cell!(|ui| ui.label(crate::format::volume(c.size)), 3);
3968                cell!(|ui| ui.label(crate::format::price_tick(c.price_tick)), 4);
3969            });
3970        }
3971    });
3972}
3973
3974/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
3975enum CtpField {
3976    Broker,
3977    TdAddr,
3978    TdUser,
3979    TdPassword,
3980    AppId,
3981    AuthCode,
3982    MdAddr,
3983}
3984
3985/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
3986fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
3987    let is_secret = matches!(field, CtpField::TdPassword | CtpField::AuthCode);
3988    let mut w = egui::TextEdit::singleline(val)
3989        .hint_text(hint)
3990        // 0.2.17:输入位加宽(旧 110 装不下一根 CTP 前置 `tcp://182.254.242.81:30001`
3991        // = 30 字符,用户看不见自己填的前置,也无法核对端口;快照钉 s39 前后对照)。
3992        .desired_width(210.0);
3993    if is_secret {
3994        w = w.password(true);
3995    }
3996    if ui.add(w).changed() {
3997        let mut f = shared.ctp_form();
3998        match field {
3999            CtpField::Broker => f.broker_id = val.clone(),
4000            CtpField::TdAddr => f.td_addr = val.clone(),
4001            CtpField::TdUser => f.td_user = val.clone(),
4002            CtpField::TdPassword => f.td_password = val.clone(),
4003            CtpField::AppId => f.td_app_id = val.clone(),
4004            CtpField::AuthCode => f.td_auth_code = val.clone(),
4005            CtpField::MdAddr => f.md_addr = val.clone(),
4006        }
4007        shared.set_ctp_form(f);
4008    }
4009}
4010
4011/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
4012///
4013/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
4014fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
4015    let Some(c) = cell else {
4016        ui.label("(K线无格:等 dm 合成)");
4017        return;
4018    };
4019    if c.bars.is_empty() {
4020        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
4021        return;
4022    }
4023    // 柱时间按北京钟面给图:egui_charts 0.2 的 Bar 只吃 DateTime<Utc> 且按
4024    // UTC 钟面画刻度 —— 用「换到 +08 再标回 UTC」的重标定,让横轴刻度显示
4025    // 北京钟面(数据面存 UTC 纳秒;重标定只挪刻度,不挪序)。
4026    let mut data = egui_charts::model::bar::BarData::new();
4027    for b in &c.bars {
4028        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
4029            .with_timezone(&beijing_tz())
4030            .with_timezone(&chrono::Utc);
4031        data.push(egui_charts::model::bar::Bar::new(
4032            dt,
4033            b.open_price,
4034            b.high_price,
4035            b.low_price,
4036            b.close_price,
4037            b.volume,
4038        ));
4039    }
4040    ui.label(format!(
4041        "K线 {}@{}s({} 根)",
4042        c.symbol,
4043        c.interval,
4044        c.bars.len()
4045    ));
4046    // 0.2.30(BD-UI-46):图表色板不再自带它那套 TradingView 令牌。
4047    // egui_charts 0.2 的 `ChartConfig::default()` 读的是它自己的 RON `DESIGN_TOKENS`
4048    // (bullish `#26a69a` 青 / bearish `#F23645` 红)——**涨跌方向正好和我们的
4049    // 红涨绿跌相反**,而且它不读 egui `Visuals`,界面换什么主题都影响不到它。
4050    // 所以这里显式覆盖:涨 = RISE(红)、跌 = FALL(绿),底走页面底、网格走 `border`
4051    // 描边、文字走正文色,让 K 线和界面是一家人(交接报告 §3 Wave B 的「ChartConfig
4052    // 显式覆盖」项;0.4.1 四期 BD-UI-57 把这三支从 qdark 换成 Nova 同源)。
4053    let cfg = egui_charts::config::ChartConfig {
4054        bullish_color: crate::tokens::semantic::RISE,
4055        bearish_color: crate::tokens::semantic::FALL,
4056        background_color: crate::tokens::shadcn::BG1,
4057        grid_color: crate::tokens::shadcn::BORDER,
4058        text_color: crate::tokens::shadcn::TEXT1,
4059        ..egui_charts::config::ChartConfig::default()
4060    };
4061    let mut chart = egui_charts::widget::Chart::with_config(data, cfg).visible_bars(60);
4062    chart.set_symbol(&c.symbol);
4063    chart.set_timeframe_label(&format!("{}s", c.interval));
4064    chart.enable_tracking_mode();
4065    let _ = chart.show(ui);
4066}
4067
4068#[cfg(test)]
4069mod tests {
4070    use super::*;
4071
4072    // 表头带**派生规则**钉(BD-UI-41,0.2.30 收缩成一套主题):
4073    // Chrome 的每个角色必须取到令牌里那一档,不许自己挑。
4074    // (色板原值本身由 `tests/ui_kittest.rs` 的 `u1_palette_matches_source` 钉 ——
4075    //  色值字面量只留在测试侧,src 侧不写 `from_rgb`。)
4076    // 原来这里还有一条「不得与金融深色那一列混同」:金融深色已随主题面删除,
4077    // 判据对象不存在,删掉,不留一条空转的断言。
4078    // 0.4.0:tab 那三档派生判据随 `tab_label` 一起撤除 —— tab 形态归 dock,
4079    // 其样式来自同一份 `visuals`(v5 钉住 `Style::from_egui` 这条派生路径)。
4080    #[test]
4081    fn chrome_derives_from_token_palette() {
4082        let v = crate::tokens::visuals();
4083        let ch = Chrome::of(&v);
4084        // 0.4.1 四期(BD-UI-57):旧口径 `line` 一支兼做「带底 + 描边」(qss:1959-1970
4085        // `QHeaderView::section { background #455364 }` 与描边同色)。Nova 把两者拆开 →
4086        // 两条断言各钉一支,谁也不许兼谁的活。
4087        assert_eq!(ch.line, crate::tokens::shadcn::BORDER);
4088        assert_eq!(ch.band_fill, crate::tokens::shadcn::BG4);
4089        assert_eq!(ch.header_text, crate::tokens::shadcn::TEXT1);
4090    }
4091
4092    // 日志入环打戳钉(BD-UI-41):日志表格化后「时间」列的事实源是 `LogRing`,
4093    // 打戳必须与表内时间列(`fmt_dt`)同一个口径 = 北京 +08,不能跟本机时区走
4094    // (本机不是 +08 时,口径错 = 日志与成交表差几小时,交易员对不上账)。
4095    #[test]
4096    fn log_entries_stamp_beijing_wall_clock() {
4097        let mut ring = crate::state::LogRing::new(4);
4098        ring.push("[ops] 一行");
4099        let e = &ring.entries()[0];
4100        assert_eq!(e.text, "[ops] 一行", "正文不得被时间戳污染");
4101        assert_eq!(e.at.len(), 8, "时间戳必须是 HH:MM:SS(8 字符),实测 {}", e.at);
4102        for seg in e.at.split(':') {
4103            assert!(seg.parse::<u32>().is_ok(), "时间戳有非数字段:{seg}");
4104        }
4105        let want = chrono::Utc::now()
4106            .with_timezone(&beijing_tz())
4107            .format("%H:%M:%S")
4108            .to_string();
4109        let secs = |s: &str| -> i64 {
4110            s.split(':')
4111                .map(|x| x.parse::<i64>().unwrap())
4112                .fold(0, |acc, v| acc * 60 + v)
4113        };
4114        let (a, b) = (secs(&e.at), secs(&want));
4115        let d = (a - b).abs();
4116        // 允许跨秒抖动,并容忍跨午夜(差值接近 86400 即同一口径的另一侧)。
4117        assert!(
4118            d <= 2 || d > 86_398,
4119            "日志时间戳必须走北京 +08:实测 {a} vs 期望 {b}"
4120        );
4121        // 旧门面不变:`snapshot()` 仍只给正文(测试与巡回取证靠它)。
4122        assert_eq!(ring.snapshot(), vec!["[ops] 一行".to_string()]);
4123    }
4124
4125    // 表内时间列北京钟面钉(0.2.13):数据面存 UTC 纳秒,展示换回 +08 ——
4126    // 北京 09:30:01 入账后是 UTC 01:30:01,fmt_dt 必须显示 09:30:01,
4127    // 不是 01:30:01(旧实现的 UTC 直显,交易员看差 8 小时)。
4128    #[test]
4129    fn fmt_dt_shows_beijing_wall_clock() {
4130        let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
4131            ek_data::time::civil_to_nanos(ek_data::time::Civil {
4132                year: 2026,
4133                month: 4,
4134                day: 16,
4135                hour: 1,
4136                minute: 30,
4137                second: 1,
4138                nanos: 0,
4139            }),
4140        );
4141        // 真值自洽:UTC 01:30:01 ↔ 北京 09:30:01(双向换算回原钟面)
4142        let beijing = expect.with_timezone(&beijing_tz());
4143        assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
4144        // 再来一个跨日钉:北京 00:00:30 = UTC 前日 16:00:30(同 UTC 日,
4145        // 不同北京日 —— 旧实现会把这根显示成 16:00:30)
4146        let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
4147            year: 2026,
4148            month: 4,
4149            day: 15,
4150            hour: 16,
4151            minute: 0,
4152            second: 30,
4153            nanos: 0,
4154        });
4155        // 0.3.1(Wave D):格式从 `HH:MM:SS` 收成 `HH:MM:SS.mmm`(照 Python `TimeCell`),
4156        // 时区口径没动 —— 这条钉的仍是「北京墙钟」这件事,不是小数位。
4157        assert_eq!(crate::format::time_hms_millis(Some(ns)), "00:00:30.000");
4158        // None → 占位
4159        assert_eq!(crate::format::time_hms_millis(None), "-");
4160    }
4161}