Skip to main content

ek_ui/
render.rs

1//! 唯一渲染入口(L2):真实 eframe App 与 kittest 建构走同一个 `render_body`。
2//!
3//! 布局 v5(BD-UI-29 面板化,官方面板组合;蓝本 = Python 主窗 mainwindow.py):
4//! **TopBottomPanel(菜单) → TopBottomPanel(行情带) → SidePanel(下单侧) →
5//! CentralPanel(2×2 监控格) → Window 弹窗**,两带物理分离。
6//! - 行情带(TopBottomPanel::resizable = Python 分带 splitter 语义,缺省 240):
7//!   交易所二级 tab(只列有 tick 的所)+ tick 表(唯一源 = `eTick.`
8//!   订阅流,不从 bar 回);行内任意格单击 = 带入下单面板(ek 增值,方案钉;
9//!   盘口四列带方向:买1 价/量 → 卖,卖1 价/量 → 买,价格列顺带填价,其余列
10//!   方向保持默认——0.2.1 `import_tick`),行内任意格双击 = 弹 K线窗。
11//! - 下单侧(SidePanel::resizable,缺省 250):下单面板常驻(Python
12//!   TradingWidget 等价:9 字段 + 委托 + 全撤;表单 = 网格,按钮/提示语都是
13//!   网格行;网格后置 err label 由 ScrollArea 吸收 sizing 退让,BD-UI-27)+
14//!   盘口(买5卖5,Python TradingWidget 内序:字段钮 → 盘口)。
15//! - 2×2 监控格(CentralPanel 内,响应式分配,无定宽定高,BD-UI-29)
16//!   「委托|未成交」「成交」「持仓」「资金|日志」(撤单钮仅活动行,既有 s11
17//!   钉口径;委托行双击 = 撤单,Python OrderMonitor 等价;格内宽表走横向滚动,
18//!   D7 口径)。
19//! - 弹窗两式(BD-UI-32):运维窗(菜单「运维」:插件/服务/依赖;0.2.10 起
20//!   流控查询钮随 sim 退场撤除)、
21//!   CTP 配置窗(菜单「网关」,CTP 插件自己的界面)= egui::Window 依附主窗;
22//!   查询合约窗(菜单「查询合约」:查询 + 勾选 + 订阅钮,Python ContractManager
23//!   等价)、K线窗(双击弹出,按合约多开)= 独立 OS 窗(show_viewport_immediate,
24//!   原生最小化/最大化/关闭,可拖出主窗;后端不支持多窗自动回退 egui::Window,
25//!   kittest 走回退面断言)。
26//! - 订阅两入口(用户裁定,网关窗不定死订阅):下单面板代码框**回车**订阅
27//!   (Python returnPressed 等价);查询合约窗**勾选订阅**。合约数据 =
28//!   `eContract.` 自动回流(ek-ctp 登录后自动查询,UI 只收不催)。
29//! - 回灌表单与票据文本框已退役(界面面);总线口(`UiCmd::Reload` 等)保留。
30//!
31//! kittest 断言面:各单元格独立 Label(`get_by_label` 直查);操作走
32//! `push_cmd` 入队(ui 插件 eTimer 辞行出队),确认状态就地翻位,零自制通道。
33//! 隔离红标:行状态含 Quarantin(快照扫描,BD-UI-17)。
34
35use ek_data::constant::{Direction, Offset, OrderType};
36use ek_station::wire::PlugRow;
37
38use crate::state::{
39    BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
40};
41
42/// 清空日志按钮的唯一可访问性标签(测试与 UI 同源)。
43pub const BTN_CLEAR_LOGS: &str = "清空日志";
44/// 取消待确认按钮标签。
45pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
46/// 下单提交按钮标签(v4 = Python「委托」钮)。
47pub const BTN_SEND_ORDER: &str = "委托";
48/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
49pub const BTN_CANCEL_ALL: &str = "全撤";
50/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
51pub const BTN_CANCEL_ORDER: &str = "撤单";
52/// CTP 连接按钮标签(S6;手工配置 → ask ctp.configure)。
53pub const BTN_CTP_CONNECT: &str = "连接 CTP";
54/// 菜单项:运维(弹运维窗)。
55pub const MENU_OPS: &str = "运维";
56/// 菜单项:网关(弹 CTP 配置窗)。
57pub const MENU_CTP: &str = "网关";
58/// 菜单项:帮助。
59pub const MENU_HELP: &str = "帮助";
60/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
61pub const MENU_CONTRACTS: &str = "查询合约";
62/// 菜单项 → 弹窗入口钮(BD-UI-31 一键直达:菜单钮即入口,一步弹窗,
63/// 不再挂下拉层;入口钮标签 = 菜单名本身)。
64/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
65pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
66
67fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
68    if armed_on == Some(name) {
69        format!("确认移除 {name} ?")
70    } else {
71        format!("移除 {name}")
72    }
73}
74
75fn state_text(r: &PlugRow) -> String {
76    r.state.clone()
77}
78
79/// DtNs → HH:MM:SS(UTC;表内时间列统一口径)。
80fn fmt_dt(dt: Option<i64>) -> String {
81    dt.map(|ns| {
82        chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
83            .format("%H:%M:%S")
84            .to_string()
85    })
86    .unwrap_or_else(|| "-".to_string())
87}
88
89// ---- BD-UI-31(R3)行高亮:悬停淡底 / 选中跳底 ----
90
91/// 行底色绘制:行内各格响应矩形并集 → 选中 = 跳底(蓝调);否则悬停 = 淡灰底。
92/// 半透明叠底(行文字先画,叠后仍可读);行交互钮单击选中与双击动作并存
93/// (egui 双击先经 clicked,选中顺带发生,无冲突)。
94fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
95    let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
96        return;
97    };
98    let hovered = ui
99        .input(|i| i.pointer.interact_pos())
100        .is_some_and(|p| union.expand(3.0).contains(p));
101    if selected {
102        ui.painter().rect_filled(
103            union.expand(3.0),
104            3.0,
105            egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
106        );
107    } else if hovered {
108        ui.painter().rect_filled(
109            union.expand(3.0),
110            3.0,
111            egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
112        );
113    }
114}
115
116/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
117fn order_active(status: ek_data::constant::Status) -> bool {
118    ek_data::constant::ACTIVE_STATUSES.contains(&status)
119}
120
121/// 格可点化(BD-UI-31 扩展):标签外观不变,在标签矩形上叠一层点击感应 ——
122/// 行内任何一格都可单击/双击(用户裁定:动作归行,不归某一列)。
123fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
124    ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
125}
126
127/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
128/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
129/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
130pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
131    let mut f = shared.order_form();
132    f.symbol = t.symbol.clone();
133    f.exchange = exchange.to_string();
134    match col {
135        8 => {
136            f.direction = Direction::SHORT;
137            f.price = format!("{:.1}", t.bid_price_1);
138        }
139        9 => f.direction = Direction::SHORT,
140        10 => {
141            f.direction = Direction::LONG;
142            f.price = format!("{:.1}", t.ask_price_1);
143        }
144        11 => f.direction = Direction::LONG,
145        _ => {}
146    }
147    shared.set_order_form(f);
148}
149
150/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
151/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
152fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
153    if symbol.is_empty() {
154        return None;
155    }
156    shared.tick_rows().into_iter().find(|t| {
157        t.symbol == symbol
158            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
159    })
160}
161
162/// 全局字号(BD-UI-32 R1,用户实看裁定「整体字偏小」,对照 Python 版):
163/// body/button/strong 16px、small 14px、heading 22px、monospace 15px
164/// (egui 缺省 13/12/18/12)。render_body 顶部统一施加,真实 App 与 kittest 同源。
165pub fn apply_ui_style(ctx: &egui::Context) {
166    apply_ui_style_for(ctx, crate::state::Theme::default());
167}
168
169/// 字号 + 主题视觉(BD-UI-34,用户问「PySide6 式可换主题」):主题 = UiShared
170/// 运行期态(菜单钮一键循环),视觉在此统一施加——真实 App、独立子窗
171/// (immediate 共享同一 Context)、kittest 三处同源。语义色(多红/空绿/
172/// 隔离红/选中蓝)不在此列:渲染侧恒定直绘,跨主题不漂移(金融语义)。
173pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
174    ctx.style_mut(|style| {
175        style
176            .text_styles
177            .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
178        style
179            .text_styles
180            .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
181        style
182            .text_styles
183            .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
184        style
185            .text_styles
186            .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
187        style
188            .text_styles
189            .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
190    });
191    ctx.set_visuals(theme_visuals(theme));
192}
193
194/// 四套主题视觉(BD-UI-34)。金融深色 = egui dark 骨架换深海军蓝底
195/// (交易终端观感,对照 Python 版深色窗);Qt 灰白 = PySide6 Fusion 迁移用。
196fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
197    use egui::Color32;
198    match t {
199        crate::state::Theme::EguiDark => egui::Visuals::dark(),
200        crate::state::Theme::EguiLight => egui::Visuals::light(),
201        crate::state::Theme::QtClassic => {
202            let mut v = egui::Visuals::light();
203            v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
204            v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
205            v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
206            v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
207            v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
208            v
209        }
210        crate::state::Theme::FinanceDark => {
211            let mut v = egui::Visuals::dark();
212            v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
213            v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
214            v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
215            v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
216            v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
217            v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
218            v.widgets.noninteractive.bg_stroke =
219                egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
220            v
221        }
222    }
223}
224
225/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
226///
227/// v5 框架(BD-UI-29 面板化):TopBottomPanel(菜单) → TopBottomPanel(行情带,
228/// resizable = Python 分带 splitter 语义) → SidePanel(下单面板 + 盘口,
229/// resizable) → CentralPanel(2×2 监控格,响应式分配,必须最后) → 弹窗。
230/// 官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、CentralPanel 最后加、
231/// Window 挂在所有面板之后。
232pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
233    apply_ui_style_for(ctx, shared.theme());
234    // ---- 菜单栏(顶层面板,最先 = 最外) ----
235    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
236        render_menu_bar(ui, shared);
237    });
238
239    // ---- 上带:行情带(全宽独占;可拖分隔条,缺省 240 ≈ Python 30:70 分带) ----
240    egui::TopBottomPanel::top("market_band")
241        .resizable(true)
242        .default_height(240.0)
243        .show(ctx, |ui| {
244            // 外层 ScrollArea auto_shrink(false) = resizable 面板吃满前提(BD-UI-27
245            // 口径;s3 实锤:撤掉后空态带塌成一行,2×2 行位全漂移)。内层
246            // pinned_table 自己会收缩适配 → 外层滚动条永不出现。
247            egui::ScrollArea::both()
248                .id_salt("market_band_scroll")
249                .auto_shrink([false, false])
250                .show(ui, |ui| {
251                    render_market_band(ui, shared);
252                });
253        });
254
255    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget 内序:字段钮 → 盘口) ----
256    egui::SidePanel::left("order_side")
257        .resizable(true)
258        .default_width(250.0)
259        .show(ctx, |ui| {
260            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
261            egui::ScrollArea::vertical()
262                .id_salt("order_side_scroll")
263                .auto_shrink([false, false])
264                .show(ui, |ui| {
265                    render_order_panel(ui, shared);
266                    ui.separator();
267                    render_book_frame(ui, shared);
268                });
269        });
270
271    // ---- 下带右侧:2×2 监控格(CentralPanel 最后;响应式分配,无定宽定高) ----
272    egui::CentralPanel::default().show(ctx, |ui| {
273        let avail = ui.available_size();
274        let sp = ui.spacing().item_spacing;
275        // 机制钉(dbg 实锤):horizontal 行成员的可用高度初始只有一行高
276        // (interact_size),高度分配不沿横向向下传播 → 定高 max_height 失效、
277        // 行按内容收缩(v4 死区根因)。官方解法 = 先精确预留矩形(group_frame:
278        // allocate_exact_size + new_child;add_sized 收 impl Widget 不适用),
279        // Frame+ScrollArea 在预留矩形内才吃满。
280        let gw = ((avail.x - sp.x) / 2.0).max(220.0);
281        let gh = ((avail.y - sp.y) / 2.0).max(120.0);
282        ui.horizontal(|ui| {
283            group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
284                render_topleft_group(ui, shared);
285            });
286            group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
287                render_trades_group(ui, shared);
288            });
289        });
290        ui.horizontal(|ui| {
291            group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
292                render_positions_group(ui, shared);
293            });
294            // 资金/日志格:保留带填充 ScrollArea 的容器(0.2.6 钉):此格无
295            // pinned_table,plain Frame 会收缩到内容宽(实测 810..1007)→ 右边框
296            // 落在行中段 = 又一根「多出来的竖线」。外层 ScrollArea
297            // auto_shrink(false) 让 Frame 吃满 gw,边界回到格位。
298            group_frame(ui, "grp_bottomright", gw, gh, |ui| {
299                render_bottomright_group(ui, shared);
300            });
301        });
302    });
303
304    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
305    render_windows(ctx, shared);
306}
307
308/// 2×2 格统一容器(响应式尺寸 + 双向滚动,BD-UI-27 可达性口径延续)。
309/// 尺寸钉(BD-UI-29):`allocate_exact_size` 精确预留 w×h 矩形 + `new_child`
310/// 在预留矩形内开 Frame+ScrollArea(dbg 实锤:horizontal 行成员的可用高度
311/// 初始只有一行高,高度分配不沿横向向下传播 → 定高 max_height 失效、行按
312/// 内容收缩 = v4 死区根因;先预留矩形,可用空间才吃满)。
313/// 布局泄漏钉(第五轮审计):Frame::show 继承父级布局 → 格内内容强制
314/// `ui.vertical`(短行成行,宽网格末位溢出走横向滚动)。
315fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
316    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
317    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
318    egui::Frame::group(ui.style()).show(&mut child, |ui| {
319        egui::ScrollArea::both()
320            .id_salt(id)
321            .auto_shrink([false, false])
322            .show(ui, |ui| {
323                ui.vertical(content);
324            });
325    });
326}
327
328/// 表格格容器(BD-UI-34):预留矩形 + Frame,但**不开外层 ScrollArea**——
329/// 滚动归 pinned_table 自己(表头固定 + 体滚 + 横滚同移),外层再包滚动
330/// = 双滚动条且表头失去固定意义。
331/// 吃满钉(0.2.6):Frame::show 的描边矩形按内容 min_rect 收缩(dbg 实锤:
332/// 内容换成一行 label 时 Frame 从 gw 宽缩到 112)——本容器只配 pinned_table
333/// (auto_shrink(false) 滚动区 = 不收缩填充物)使用;非表格内容走 group_frame。
334fn group_frame_plain(
335    ui: &mut egui::Ui,
336    _id: &str,
337    w: f32,
338    h: f32,
339    content: impl FnOnce(&mut egui::Ui),
340) {
341    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
342    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
343    egui::Frame::group(ui.style()).show(&mut child, |ui| {
344        ui.vertical(content);
345    });
346}
347
348/// 表头固定表(BD-UI-34,用户裁定「每个表的表头都要固定」):
349/// - 单 grid 实例(头行 + 体行)在 both ScrollArea 内——列对齐天然、无同 id
350///   双 grid 病(dbg 实锤:两 grid 同 id 各从空白重建 State,save 交替
351///   request_repaint → kittest max_steps 爆)。
352/// - 表头固定 = 官方 DIY 模式(scroll_area.rs:412「painting a sticky header
353///   on top of it」):ScrollArea 之后,用本拍捕获的表头格矩形在滚动区顶部
354///   重画一条表头带(背景盖行 + 文字重画)。格矩形已含滚动平移 → 横向自动
355///   跟随、纵向永远盖顶,零拍滞后。带高 = 表头格高 + 行间距(0.2.6 修:只盖
356///   格高时,滚动行从行间距带里露 3px = 用户报「表头缝露内容」)。
357/// - 覆盖带 = 纯 painter 绘制(0.2.6 修,用户报「资金右多一根竖线」根因):
358///   旧版 ui.put 重画视口外表头格(16 列总宽撑到 x=1222),布局 min_rect
359///   被撑宽 → Frame 描边矩形跟着撑 → 格右边界画在内容中段。painter.text/
360///   rect_filled 零布局副作用;越界文字由 clip 裁掉。
361///
362/// body 闭包在体表 grid 内执行(行循环 + end_row 归闭包)。
363fn pinned_table(
364    ui: &mut egui::Ui,
365    grid_id: &str,
366    num_columns: usize,
367    headers: &[&str],
368    body: impl FnOnce(&mut egui::Ui),
369) {
370    let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
371        std::cell::RefCell::new(Vec::new());
372    let resp = egui::ScrollArea::both()
373        .id_salt(format!("{grid_id}_sc"))
374        .auto_shrink([false, false])
375        .show(ui, |ui| {
376            egui::Grid::new(grid_id)
377                .striped(true)
378                .num_columns(num_columns)
379                .show(ui, |ui| {
380                    for h in headers {
381                        let r = ui.strong(*h);
382                        heads_cell.borrow_mut().push((r.rect, h.to_string()));
383                    }
384                    ui.end_row();
385                    body(ui);
386                });
387        });
388    let heads = heads_cell.borrow();
389    if heads.is_empty() {
390        return;
391    }
392    let area = resp.inner_rect;
393    let head_h = heads[0].0.height();
394    // 覆盖带几何(0.2.8,BD-UI-35 缝隙根因):ScrollArea 内容裁剪 = inner_rect
395    // 向外扩 visuals().clip_rect_margin(默认 3.0,style.rs:1435;
396    // scroll_area.rs:771-772)——带子若只从 inner_rect 顶边起画,滚动行会从
397    // 带子上方 3px 余量里露出(用户报「表头上面有缝」)。带子向上/左右各扩
398    // 该余量,向下盖 表头格高 + grid 行间距。clip 同步 = 带矩形。
399    let m = ui.visuals().clip_rect_margin;
400    let strip = egui::Rect::from_min_max(
401        egui::pos2(area.min.x - m, area.min.y - m),
402        egui::pos2(
403            area.max.x + m,
404            area.min.y + head_h + ui.spacing().item_spacing.y,
405        ),
406    );
407    ui.scope_builder(
408        egui::UiBuilder::new()
409            .id_salt(format!("{grid_id}_hdr_ov"))
410            .max_rect(strip),
411        |ui| {
412            ui.set_clip_rect(strip);
413            let painter = ui.painter();
414            painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
415            let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
416            let color = ui.visuals().strong_text_color();
417            for (rect, text) in heads.iter() {
418                painter.text(
419                    egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
420                    egui::Align2::LEFT_CENTER,
421                    text.as_str(),
422                    font.clone(),
423                    color,
424                );
425            }
426        },
427    );
428}
429
430// ================= 菜单栏 =================
431
432fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
433    ui.horizontal(|ui| {
434        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
435        if ui.button(MENU_OPS).clicked() {
436            shared.set_ops_window_open(true);
437        }
438        if ui.button(MENU_CTP).clicked() {
439            shared.set_ctp_window_open(true);
440        }
441        if ui.button(MENU_CONTRACTS).clicked() {
442            shared.set_contracts_window_open(true);
443        }
444        ui.menu_button(MENU_HELP, |ui| {
445            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
446            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
447        });
448        // BD-UI-34:主题一键循环(金融深色→egui深色→egui浅色→Qt灰白→…;
449        // 单层钮无下拉,与 R1「菜单钮即入口」同口径)。
450        if ui
451            .button(format!("主题:{}", shared.theme().label()))
452            .clicked()
453        {
454            let t = shared.cycle_theme();
455            shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
456        }
457    });
458}
459
460// ================= 行情带 =================
461
462fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
463    // BD-UI-31(R4):表题文字撤除(带位即身份,Python TickMonitor 无题同口径)。
464    let groups = shared.tick_groups();
465    if groups.is_empty() {
466        ui.label("(等待 eTick 订阅)");
467        return;
468    }
469    // 二级 tab:只列有 tick 的所
470    let sel = shared.selected_exchange();
471    let active = if groups.iter().any(|(e, _)| *e == sel) {
472        sel.clone()
473    } else {
474        groups[0].0.clone()
475    };
476    ui.horizontal(|ui| {
477        for (ex, _) in &groups {
478            if ui.selectable_label(*ex == active, ex.clone()).clicked() {
479                shared.set_selected_exchange(ex.clone());
480            }
481        }
482    });
483    let ticks = groups
484        .iter()
485        .find(|(e, _)| *e == active)
486        .map(|(_, v)| v.clone())
487        .unwrap_or_default();
488    // 状态行「tick N 格 / 最后更新 …」撤除(BD-UI-35,用户裁定:此信息早已
489    // 不需要,行情带 tab + 表格即全部)。
490    pinned_table(
491        ui,
492        "tick_grid",
493        14,
494        &[
495            "代码",
496            "名称",
497            "最新价",
498            "涨跌幅",
499            "成交量",
500            "今开",
501            "最高",
502            "最低",
503            "买1价",
504            "买1量",
505            "卖1价",
506            "卖1量",
507            "时间",
508            "接口",
509        ],
510        |ui| {
511            for t in &ticks {
512                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
513                // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
514                // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
515                // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
516                let row_sel = shared.order_form().symbol == t.symbol;
517                let mut cells = Vec::with_capacity(14);
518                let mut dbl = false;
519                let resp = ui.button(t.symbol.clone());
520                if resp.clicked() {
521                    shared.set_selected_exchange(active.clone());
522                    import_tick(shared, t, &active, 0);
523                }
524                dbl |= resp.double_clicked();
525                cells.push(resp.rect);
526                macro_rules! cell {
527                    ($base:expr, $col:expr) => {{
528                        let base = $base;
529                        let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
530                        if r.clicked() {
531                            shared.set_selected_exchange(active.clone());
532                            import_tick(shared, t, &active, $col);
533                        }
534                        dbl |= r.double_clicked();
535                        cells.push(base.rect);
536                    }};
537                }
538                cell!(
539                    ui.label(if t.name.is_empty() {
540                        "-".to_string()
541                    } else {
542                        t.name.clone()
543                    }),
544                    1
545                );
546                cell!(ui.label(format!("{:.1}", t.last_price)), 2);
547                cell!(
548                    ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
549                        if t.pct_change < 0.0 {
550                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
551                        } else {
552                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
553                        },
554                    )),
555                    3
556                );
557                cell!(ui.label(format!("{:.0}", t.volume)), 4);
558                cell!(ui.label(format!("{:.1}", t.open_price)), 5);
559                cell!(ui.label(format!("{:.1}", t.high_price)), 6);
560                cell!(ui.label(format!("{:.1}", t.low_price)), 7);
561                cell!(
562                    ui.label(
563                        egui::RichText::new(format!("{:.1}", t.bid_price_1))
564                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
565                    ),
566                    8
567                );
568                cell!(
569                    ui.label(
570                        egui::RichText::new(format!("{:.0}", t.bid_volume_1))
571                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
572                    ),
573                    9
574                );
575                cell!(
576                    ui.label(
577                        egui::RichText::new(format!("{:.1}", t.ask_price_1))
578                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
579                    ),
580                    10
581                );
582                cell!(
583                    ui.label(
584                        egui::RichText::new(format!("{:.0}", t.ask_volume_1))
585                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
586                    ),
587                    11
588                );
589                cell!(
590                    ui.label(if t.update_time.is_empty() {
591                        fmt_dt(Some(t.datetime))
592                    } else {
593                        t.update_time.clone()
594                    }),
595                    12
596                );
597                cell!(ui.label(t.gateway_name.clone()), 13);
598                if dbl {
599                    shared.open_kline(t.symbol.clone());
600                }
601                paint_row_bg(ui, &cells, row_sel);
602                ui.end_row();
603            }
604        },
605    );
606}
607
608// ================= 下单面板(常驻) =================
609
610fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
611    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
612    let mut f = shared.order_form();
613    let before = f.clone();
614    let mut err: Option<String> = None;
615
616    // 随价勾选:价格框随该合约最新 tick(勾选态)
617    if f.follow_price {
618        if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
619            if t.last_price > 0.0 {
620                f.price = format!("{:.1}", t.last_price);
621            }
622        }
623    }
624
625    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
626    let mut ex_opts: Vec<String> = shared
627        .tick_groups()
628        .iter()
629        .map(|(e, _)| e.clone())
630        .collect();
631    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
632        if !ex_opts.iter().any(|x| x == e) {
633            ex_opts.push(e.to_string());
634        }
635    }
636    if !ex_opts.contains(&f.exchange) {
637        ex_opts.insert(0, f.exchange.clone());
638    }
639    // 接口下拉:trade_snap 网关去重集 ∪ {ctp}(审计钉:不新增总线口;
640    // 0.2.10 BD-UI-38:sim 纸面网关退场)
641    let ts = shared.trade_snap();
642    let mut gw_opts: Vec<String> = Vec::new();
643    for g in ts
644        .orders
645        .iter()
646        .map(|o| o.gateway_name.clone())
647        .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
648        .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
649    {
650        if !g.is_empty() && !gw_opts.contains(&g) {
651            gw_opts.push(g);
652        }
653    }
654    for g in ["ctp"] {
655        if !gw_opts.iter().any(|x| x == g) {
656            gw_opts.push(g.to_string());
657        }
658    }
659    if !gw_opts.contains(&f.gateway) {
660        gw_opts.insert(0, f.gateway.clone());
661    }
662
663    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
664    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
665    egui::Grid::new("order_form_grid")
666        .num_columns(2)
667        .show(ui, |ui| {
668            ui.label("交易所");
669            egui::ComboBox::from_id_salt("of_exchange")
670                .selected_text(f.exchange.clone())
671                .show_ui(ui, |ui| {
672                    for e in &ex_opts {
673                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
674                    }
675                });
676            ui.end_row();
677            ui.label("代码");
678            let sym_resp = ui.add(
679                egui::TextEdit::singleline(&mut f.symbol)
680                    .hint_text("cu2709")
681                    .desired_width(150.0),
682            );
683            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
684            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
685            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
686            // has_focus 口径回车订阅从不触发)。
687            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
688                let s = f.symbol.trim().to_string();
689                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
690                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
691                let bare = match s.split_once('.') {
692                    Some((_, b)) => b.trim().to_string(),
693                    None => s,
694                };
695                if !bare.is_empty() {
696                    f.symbol = bare.clone();
697                    shared.push_cmd(UiCmd::CtpSubscribe {
698                        symbols: vec![bare.clone()],
699                    });
700                    shared.log_line(format!("[ops] 订阅已入列 {bare}(代码回车订阅)"));
701                }
702            }
703            ui.end_row();
704            ui.label("名称");
705            ui.label(
706                tick_of(shared, &f.exchange, &f.symbol)
707                    .map(|t| {
708                        if t.name.is_empty() {
709                            "-".to_string()
710                        } else {
711                            t.name
712                        }
713                    })
714                    .unwrap_or_else(|| "-".to_string()),
715            );
716            ui.end_row();
717            ui.label("方向");
718            egui::ComboBox::from_id_salt("of_direction")
719                .selected_text(f.direction.as_str())
720                .show_ui(ui, |ui| {
721                    for d in [Direction::LONG, Direction::SHORT] {
722                        ui.selectable_value(&mut f.direction, d, d.as_str());
723                    }
724                });
725            ui.end_row();
726            ui.label("开平");
727            ui.horizontal(|ui| {
728                egui::ComboBox::from_id_salt("of_offset")
729                    .selected_text(f.offset.as_str())
730                    .show_ui(ui, |ui| {
731                        for o in [
732                            Offset::OPEN,
733                            Offset::CLOSE,
734                            Offset::CLOSETODAY,
735                            Offset::CLOSEYESTERDAY,
736                        ] {
737                            ui.selectable_value(&mut f.offset, o, o.as_str());
738                        }
739                    });
740                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
741                ui.checkbox(&mut f.auto_split, "自动开平");
742            });
743            ui.end_row();
744            ui.label("类型");
745            egui::ComboBox::from_id_salt("of_order_type")
746                .selected_text(f.order_type.as_str())
747                .show_ui(ui, |ui| {
748                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
749                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
750                    }
751                });
752            ui.end_row();
753            ui.label("价格");
754            ui.horizontal(|ui| {
755                ui.add(
756                    egui::TextEdit::singleline(&mut f.price)
757                        .hint_text("107660")
758                        .desired_width(90.0),
759                );
760                ui.checkbox(&mut f.follow_price, "随价");
761            });
762            ui.end_row();
763            ui.label("数量");
764            ui.add(
765                egui::TextEdit::singleline(&mut f.volume)
766                    .hint_text("1")
767                    .desired_width(90.0),
768            );
769            ui.end_row();
770            ui.label("接口");
771            egui::ComboBox::from_id_salt("of_gateway")
772                .selected_text(if f.gateway.is_empty() {
773                    "ctp".to_string()
774                } else {
775                    f.gateway.clone()
776                })
777                .show_ui(ui, |ui| {
778                    for g in &gw_opts {
779                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
780                    }
781                });
782            ui.end_row();
783            ui.label("操作");
784            ui.horizontal(|ui| {
785                if ui.button(BTN_SEND_ORDER).clicked() {
786                    match f.to_request() {
787                        Ok(req) => match crate::state::parse_gateway(&f.gateway) {
788                            Ok(gateway) => {
789                                shared.push_cmd(UiCmd::SendOrder {
790                                    req,
791                                    gateway,
792                                    mode: if f.auto_split {
793                                        ek_oms::LockMode::Auto
794                                    } else {
795                                        ek_oms::LockMode::None
796                                    },
797                                });
798                                shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
799                            }
800                            Err(e) => {
801                                shared.log_line(format!("[下单] {e}"));
802                                err = Some(e);
803                            }
804                        },
805                        Err(e) => {
806                            shared.log_line(format!("[下单] {e}"));
807                            err = Some(e);
808                        }
809                    }
810                }
811                if ui.button(BTN_CANCEL_ALL).clicked() {
812                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
813                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
814                    let mut n = 0usize;
815                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
816                        let gw = if o.gateway_name.is_empty() {
817                            "ctp".to_string()
818                        } else {
819                            o.gateway_name.clone()
820                        };
821                        shared.push_cmd(UiCmd::CancelOrder {
822                            orderid: o.orderid.clone(),
823                            gateway: gw,
824                        });
825                        n += 1;
826                    }
827                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
828                }
829            });
830            ui.end_row();
831            ui.label("代码回车 = 订阅");
832            ui.label("");
833            ui.end_row();
834        });
835    if let Some(e) = err {
836        ui.label(egui::RichText::new(e).color(egui::Color32::RED));
837    }
838    if f != before {
839        shared.set_order_form(f.clone());
840    }
841}
842
843/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
844/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
845/// 行情带跳色行指认,盘口帧不重复报幕)。
846fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
847    let f = shared.order_form();
848    match tick_of(shared, &f.exchange, &f.symbol) {
849        Some(t) => {
850            for i in (1..=5).rev() {
851                let (p, v) = match i {
852                    1 => (t.ask_price_1, t.ask_volume_1),
853                    2 => (t.ask_price_2, t.ask_volume_2),
854                    3 => (t.ask_price_3, t.ask_volume_3),
855                    4 => (t.ask_price_4, t.ask_volume_4),
856                    _ => (t.ask_price_5, t.ask_volume_5),
857                };
858                ui.label(
859                    egui::RichText::new(format!("卖{i}  {:.1}  {:.0}", p, v))
860                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
861                );
862            }
863            for i in 1..=5 {
864                let (p, v) = match i {
865                    1 => (t.bid_price_1, t.bid_volume_1),
866                    2 => (t.bid_price_2, t.bid_volume_2),
867                    3 => (t.bid_price_3, t.bid_volume_3),
868                    4 => (t.bid_price_4, t.bid_volume_4),
869                    _ => (t.bid_price_5, t.bid_volume_5),
870                };
871                ui.label(
872                    egui::RichText::new(format!("买{i}  {:.1}  {:.0}", p, v))
873                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
874                );
875            }
876        }
877        None => {
878            ui.label("(无 tick)");
879        }
880    }
881}
882
883// ================= 2×2 监控格 =================
884
885fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
886    let tab = shared.topleft_tab();
887    ui.horizontal(|ui| {
888        if ui
889            .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
890            .clicked()
891        {
892            shared.set_topleft_tab(TopLeftTab::Orders);
893        }
894        if ui
895            .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
896            .clicked()
897        {
898            shared.set_topleft_tab(TopLeftTab::Active);
899        }
900    });
901    render_orders_table(ui, shared, tab == TopLeftTab::Active);
902}
903
904/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
905/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
906/// 活动行撤单钮 + 双击撤单,终态行只读)。
907fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
908    let ts = shared.trade_snap();
909    // 表题撤除(第五轮审计):tab「委托|未成交」即 Python 口径的表身份,
910    // 再挂同名 label = 三重撞名(下单钮/tab/表题),纯冗余。
911    let rows: Vec<_> = ts
912        .orders
913        .iter()
914        .filter(|o| !active_only || order_active(o.status))
915        .collect();
916    // 委托表列头单源(空/非空两分支同源,0.2.7 审计口径)。
917    const ORDER_HEADERS: [&str; 17] = [
918        "代码",
919        "名称",
920        "交易所",
921        "类型",
922        "方向",
923        "开平",
924        "价格",
925        "总量",
926        "已成",
927        "成交均价",
928        "状态",
929        "状态描述",
930        "时间",
931        "委托号",
932        "来源",
933        "接口",
934        "操作",
935    ];
936    if rows.is_empty() {
937        // 空态提示移入表格 body(BD-UI-35,用户裁定:提示不得在表头上方,
938        // 应在表头之下):label 成为 grid 首格 = 表头下第一行,格不缩。
939        pinned_table(
940            ui,
941            if active_only {
942                "active_grid"
943            } else {
944                "order_grid"
945            },
946            17,
947            &ORDER_HEADERS,
948            |ui| {
949                ui.label(if active_only {
950                    "(无未成交)"
951                } else {
952                    "(无委托:等网关 send_order)"
953                });
954            },
955        );
956        return;
957    }
958    pinned_table(
959        ui,
960        if active_only {
961            "active_grid"
962        } else {
963            "order_grid"
964        },
965        17,
966        &ORDER_HEADERS,
967        |ui| {
968            for o in &rows {
969                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
970                // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
971                // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
972                let key = format!("{}.{}", o.gateway_name, o.orderid);
973                let row_sel = shared.row_selected("orders", &key);
974                let mut cells = Vec::with_capacity(17);
975                let mut dbl = false;
976                let sel = ui.selectable_label(row_sel, o.symbol.clone());
977                if sel.clicked() {
978                    shared.select_row("orders", &key);
979                }
980                dbl |= sel.double_clicked();
981                cells.push(sel.rect);
982                macro_rules! cell {
983                    ($base:expr, $col:expr) => {{
984                        let base = $base;
985                        let r = click_cell(ui, base.rect, ("ord", &key, $col));
986                        if r.clicked() {
987                            shared.select_row("orders", &key);
988                        }
989                        dbl |= r.double_clicked();
990                        cells.push(base.rect);
991                    }};
992                }
993                cell!(
994                    ui.label(if o.name.is_empty() {
995                        "-".to_string()
996                    } else {
997                        o.name.clone()
998                    }),
999                    1
1000                );
1001                cell!(ui.label(o.exchange.as_str().to_string()), 2);
1002                cell!(ui.label(o.order_type.as_str().to_string()), 3);
1003                cell!(
1004                    ui.label(
1005                        egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1006                            if o.direction == Some(Direction::SHORT) {
1007                                egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1008                            } else {
1009                                egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1010                            },
1011                        ),
1012                    ),
1013                    4
1014                );
1015                cell!(ui.label(o.offset.as_str().to_string()), 5);
1016                cell!(ui.label(format!("{:.1}", o.price)), 6);
1017                cell!(ui.label(format!("{:.0}", o.volume)), 7);
1018                cell!(ui.label(format!("{:.0}", o.traded)), 8);
1019                cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1020                cell!(
1021                    ui.label(egui::RichText::new(o.status.as_str()).color(
1022                        if o.status == ek_data::constant::Status::REJECTED {
1023                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1024                        } else if o.status == ek_data::constant::Status::ALLTRADED {
1025                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1026                        } else {
1027                            egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1028                        },
1029                    )),
1030                    10
1031                );
1032                cell!(ui.label(o.status_msg.clone()), 11);
1033                cell!(ui.label(fmt_dt(o.datetime)), 12);
1034                cell!(ui.label(o.orderid.clone()), 13);
1035                cell!(
1036                    ui.label(if o.reference.is_empty() {
1037                        "-".to_string()
1038                    } else {
1039                        o.reference.clone()
1040                    }),
1041                    14
1042                );
1043                cell!(ui.label(o.gateway_name.clone()), 15);
1044                if dbl && order_active(o.status) {
1045                    let gw = if o.gateway_name.is_empty() {
1046                        "ctp".to_string()
1047                    } else {
1048                        o.gateway_name.clone()
1049                    };
1050                    shared.push_cmd(UiCmd::CancelOrder {
1051                        orderid: o.orderid.clone(),
1052                        gateway: gw,
1053                    });
1054                }
1055                let hr = ui.horizontal(|ui| {
1056                    if order_active(o.status)
1057                        && ui
1058                            .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1059                            .clicked()
1060                    {
1061                        // 撤单跟随委托的来路网关(行级 gateway_name;空串兜底 ctp)
1062                        let gw = if o.gateway_name.is_empty() {
1063                            "ctp".to_string()
1064                        } else {
1065                            o.gateway_name.clone()
1066                        };
1067                        shared.push_cmd(UiCmd::CancelOrder {
1068                            orderid: o.orderid.clone(),
1069                            gateway: gw,
1070                        });
1071                    }
1072                });
1073                cells.push(hr.response.rect);
1074                paint_row_bg(ui, &cells, row_sel);
1075                ui.end_row();
1076            }
1077        },
1078    );
1079}
1080
1081/// 成交组(12 列逐值 = Python TradeMonitor 蓝图)。
1082fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1083    ui.label("成交");
1084    let ts = shared.trade_snap();
1085    // 成交表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1086    const TRADE_HEADERS: [&str; 12] = [
1087        "成交号",
1088        "代码",
1089        "名称",
1090        "交易所",
1091        "方向",
1092        "开平",
1093        "价格",
1094        "数量",
1095        "成交额",
1096        "时间",
1097        "委托号",
1098        "接口",
1099    ];
1100    if ts.trades.is_empty() {
1101        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
1102        pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1103            ui.label("(无成交)");
1104        });
1105        return;
1106    }
1107    pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1108        for t in &ts.trades {
1109            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1110            // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
1111            let key = format!("{}.{}", t.gateway_name, t.tradeid);
1112            let row_sel = shared.row_selected("trades", &key);
1113            let mut cells = Vec::with_capacity(12);
1114            let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1115            if sel.clicked() {
1116                shared.select_row("trades", &key);
1117            }
1118            cells.push(sel.rect);
1119            macro_rules! cell {
1120                ($base:expr, $col:expr) => {{
1121                    let base = $base;
1122                    let r = click_cell(ui, base.rect, ("trd", &key, $col));
1123                    if r.clicked() {
1124                        shared.select_row("trades", &key);
1125                    }
1126                    cells.push(base.rect);
1127                }};
1128            }
1129            cell!(ui.label(t.symbol.clone()), 1);
1130            cell!(
1131                ui.label(if t.name.is_empty() {
1132                    "-".to_string()
1133                } else {
1134                    t.name.clone()
1135                }),
1136                2
1137            );
1138            cell!(ui.label(t.exchange.as_str().to_string()), 3);
1139            cell!(
1140                ui.label(
1141                    egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1142                        if t.direction == Some(Direction::SHORT) {
1143                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1144                        } else {
1145                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1146                        },
1147                    ),
1148                ),
1149                4
1150            );
1151            cell!(ui.label(t.offset.as_str().to_string()), 5);
1152            cell!(ui.label(format!("{:.1}", t.price)), 6);
1153            cell!(ui.label(format!("{:.0}", t.volume)), 7);
1154            cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1155            cell!(ui.label(fmt_dt(t.datetime)), 9);
1156            cell!(ui.label(t.orderid.clone()), 10);
1157            cell!(ui.label(t.gateway_name.clone()), 11);
1158            paint_row_bg(ui, &cells, row_sel);
1159            ui.end_row();
1160        }
1161    });
1162}
1163
1164/// 持仓组(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
1165/// Python mainwindow.py:276 等价,不开 K线)。
1166fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1167    ui.label("持仓");
1168    let ts = shared.trade_snap();
1169    // 持仓表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1170    const POS_HEADERS: [&str; 16] = [
1171        "代码",
1172        "名称",
1173        "交易所",
1174        "方向",
1175        "数量",
1176        "昨仓",
1177        "可用",
1178        "冻结",
1179        "在途",
1180        "市值",
1181        "成本",
1182        "均价",
1183        "最新价",
1184        "盈亏",
1185        "盈亏比",
1186        "接口",
1187    ];
1188    if ts.positions.is_empty() {
1189        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
1190        pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1191            ui.label("(无持仓)");
1192        });
1193        return;
1194    }
1195    pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1196        for p in &ts.positions {
1197            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1198            // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
1199            // 不开 K线;方向随持仓方向,Python 同口径)。
1200            let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1201            let row_sel = shared.row_selected("positions", &key);
1202            let mut cells = Vec::with_capacity(16);
1203            let mut dbl = false;
1204            let sel = ui.selectable_label(row_sel, p.symbol.clone());
1205            if sel.clicked() {
1206                shared.select_row("positions", &key);
1207            }
1208            dbl |= sel.double_clicked();
1209            cells.push(sel.rect);
1210            macro_rules! cell {
1211                ($base:expr, $col:expr) => {{
1212                    let base = $base;
1213                    let r = click_cell(ui, base.rect, ("pos", &key, $col));
1214                    if r.clicked() {
1215                        shared.select_row("positions", &key);
1216                    }
1217                    dbl |= r.double_clicked();
1218                    cells.push(base.rect);
1219                }};
1220            }
1221            cell!(
1222                ui.label(if p.name.is_empty() {
1223                    "-".to_string()
1224                } else {
1225                    p.name.clone()
1226                }),
1227                1
1228            );
1229            cell!(ui.label(p.exchange.as_str().to_string()), 2);
1230            cell!(
1231                ui.label(egui::RichText::new(p.direction.as_str()).color(
1232                    if p.direction == ek_data::constant::Direction::SHORT {
1233                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1234                    } else {
1235                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1236                    },
1237                )),
1238                3
1239            );
1240            cell!(ui.label(format!("{:.0}", p.volume)), 4);
1241            cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1242            cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1243            cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1244            cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1245            cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1246            cell!(ui.label(format!("{:.2}", p.price)), 10);
1247            cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1248            cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1249            cell!(
1250                ui.label(
1251                    egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1252                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1253                    } else {
1254                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1255                    }),
1256                ),
1257                13
1258            );
1259            cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1260            cell!(ui.label(p.gateway_name.clone()), 15);
1261            if dbl {
1262                let mut f = shared.order_form();
1263                f.symbol = p.symbol.clone();
1264                f.exchange = p.exchange.as_str().to_string();
1265                f.direction = p.direction;
1266                shared.set_order_form(f);
1267                shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
1268            }
1269            paint_row_bg(ui, &cells, row_sel);
1270            ui.end_row();
1271        }
1272    });
1273}
1274
1275fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1276    let tab = shared.bottomright_tab();
1277    ui.horizontal(|ui| {
1278        if ui
1279            .selectable_label(
1280                tab == BottomRightTab::Account,
1281                BottomRightTab::Account.label(),
1282            )
1283            .clicked()
1284        {
1285            shared.set_bottomright_tab(BottomRightTab::Account);
1286        }
1287        if ui
1288            .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1289            .clicked()
1290        {
1291            shared.set_bottomright_tab(BottomRightTab::Logs);
1292        }
1293    });
1294    match tab {
1295        BottomRightTab::Account => {
1296            let ts = shared.trade_snap();
1297            if ts.accounts.is_empty() {
1298                ui.label("(无资金:等网关 eAccount.)");
1299                return;
1300            }
1301            egui::Grid::new("acct_grid")
1302                .striped(true)
1303                .num_columns(5)
1304                .show(ui, |ui| {
1305                    for h in ["账号", "接口", "余额", "可用", "冻结"] {
1306                        ui.strong(h);
1307                    }
1308                    ui.end_row();
1309                    for a in &ts.accounts {
1310                        ui.label(a.accountid.clone());
1311                        ui.label(a.gateway_name.clone());
1312                        ui.label(format!("{:.2}", a.balance));
1313                        ui.label(format!("{:.2}", a.available()));
1314                        ui.label(format!("{:.2}", a.frozen));
1315                        ui.end_row();
1316                    }
1317                });
1318        }
1319        BottomRightTab::Logs => {
1320            render_logs(ui, shared);
1321        }
1322    }
1323}
1324
1325fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1326    ui.horizontal(|ui| {
1327        if ui.button(BTN_CLEAR_LOGS).clicked() {
1328            shared.clear_logs(); // LOCAL-FIRST:就地清,零往返
1329        }
1330        let (_, spilled) = shared.logs_snapshot();
1331        if spilled > 0 {
1332            ui.label(format!(
1333                "已丢弃 {spilled} 条(环形 {})",
1334                crate::state::RING_CAP
1335            ));
1336        }
1337    });
1338    let lines = shared.logs_snapshot().0;
1339    egui::ScrollArea::vertical()
1340        .id_salt("log_scroll")
1341        .max_height(180.0)
1342        .stick_to_bottom(true)
1343        .show(ui, |ui| {
1344            for line in lines {
1345                ui.monospace(line);
1346            }
1347        });
1348}
1349
1350// ================= 独立窗面(BD-UI-32/33)=================
1351
1352/// 独立窗几何(BD-UI-34:助手参数收敛,8→6 闸 clippy too_many_arguments)。
1353struct WinGeom {
1354    inner: [f32; 2],
1355    min_inner: [f32; 2],
1356    maximized: bool,
1357}
1358
1359/// 独立 OS 窗统一开法(BD-UI-33,四窗一型:运维/CTP/查询合约/K线)。
1360///
1361/// - **守卫(0.2.3 教训内建):** 调用方按状态位辞行——位关不调本函数 =
1362///   viewport 本拍未 used = egui/eframe GC 销毁 OS 窗(销毁判据源码
1363///   context.rs「never used this pass」)。
1364/// - Embedded 回退(后端不支持多窗:kittest/WEB)= 主窗内 egui::Window 同本体,
1365///   回退面关闭 = embedded 闭包返回 true。
1366/// - Immediate 面 OS X = close_requested → 返回 true(调用方翻负状态位)。
1367/// - `geom.maximized` = 打开即最大化(BD-UI-34,K线窗用户裁定;OS 窗属性,
1368///   Embedded 回退面无此概念 = 忽略)。
1369/// - `body` 返回 true = 内容侧请求关闭(BD-UI-34:K线窗 ESC 键快捷关闭;
1370///   键事件按子窗焦点路由,主窗按键不会误关)。
1371///
1372/// 返回 = "本拍该窗被关"(调用方负责状态位/列表出表)。
1373fn show_independent_window(
1374    ctx: &egui::Context,
1375    id_salt: impl std::hash::Hash,
1376    title: &str,
1377    geom: &WinGeom,
1378    embedded: impl Fn(&egui::Context) -> bool,
1379    body: impl Fn(&mut egui::Ui) -> bool,
1380) -> bool {
1381    let closed = std::cell::Cell::new(false);
1382    let mut builder = egui::ViewportBuilder::default()
1383        .with_title(title)
1384        .with_inner_size(geom.inner)
1385        .with_min_inner_size(geom.min_inner)
1386        .with_minimize_button(true)
1387        .with_maximize_button(true)
1388        .with_close_button(true);
1389    if geom.maximized {
1390        builder = builder.with_maximized(true);
1391    }
1392    ctx.show_viewport_immediate(
1393        egui::ViewportId::from_hash_of(id_salt),
1394        builder,
1395        |ctx2, class| {
1396            if class == egui::ViewportClass::Embedded {
1397                closed.set(embedded(ctx2));
1398                return;
1399            }
1400            if ctx2.input(|i| i.viewport().close_requested()) {
1401                closed.set(true);
1402                return;
1403            }
1404            egui::CentralPanel::default().show(ctx2, |ui| {
1405                if body(ui) {
1406                    closed.set(true);
1407                }
1408            });
1409        },
1410    );
1411    closed.get()
1412}
1413
1414fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1415    // 运维窗(菜单「运维」;BD-UI-33 转独立 OS 窗,用户裁定「四窗皆可脱离主窗」)。
1416    if shared.ops_window_open() {
1417        let closed = show_independent_window(
1418            ctx,
1419            "ops_win",
1420            "运维窗",
1421            &WinGeom {
1422                inner: [680.0, 420.0],
1423                min_inner: [420.0, 260.0],
1424                maximized: false,
1425            },
1426            |ctx2| {
1427                let mut open = shared.ops_window_open();
1428                egui::Window::new("运维窗")
1429                    .id(egui::Id::new("ops_win"))
1430                    .default_pos([80.0, 320.0])
1431                    .default_size([640.0, 380.0])
1432                    .open(&mut open)
1433                    .show(ctx2, |ui| {
1434                        render_ops_window(ui, shared);
1435                    });
1436                shared.set_ops_window_open(open);
1437                !open
1438            },
1439            |ui| {
1440                render_ops_window(ui, shared);
1441                false
1442            },
1443        );
1444        if closed {
1445            shared.set_ops_window_open(false);
1446        }
1447    }
1448
1449    // CTP 配置窗(菜单「网关」;CTP 插件自己的界面;BD-UI-33 转独立 OS 窗)。
1450    if shared.ctp_window_open() {
1451        let closed = show_independent_window(
1452            ctx,
1453            "ctp_win",
1454            "CTP 网关配置窗",
1455            &WinGeom {
1456                inner: [600.0, 380.0],
1457                min_inner: [420.0, 260.0],
1458                maximized: false,
1459            },
1460            |ctx2| {
1461                let mut open = shared.ctp_window_open();
1462                egui::Window::new("CTP 网关配置窗")
1463                    .id(egui::Id::new("ctp_win"))
1464                    .default_pos([640.0, 320.0])
1465                    .default_size([560.0, 330.0])
1466                    .open(&mut open)
1467                    .show(ctx2, |ui| {
1468                        render_ctp_window(ui, shared);
1469                    });
1470                shared.set_ctp_window_open(open);
1471                !open
1472            },
1473            |ui| {
1474                render_ctp_window(ui, shared);
1475                false
1476            },
1477        );
1478        if closed {
1479            shared.set_ctp_window_open(false);
1480        }
1481    }
1482
1483    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)。
1484    if shared.contracts_window_open() {
1485        let closed = show_independent_window(
1486            ctx,
1487            "contracts_win",
1488            "查询合约窗",
1489            &WinGeom {
1490                inner: [620.0, 460.0],
1491                min_inner: [420.0, 260.0],
1492                maximized: false,
1493            },
1494            |ctx2| {
1495                let mut open = shared.contracts_window_open();
1496                egui::Window::new("查询合约窗")
1497                    .id(egui::Id::new("contracts_win"))
1498                    .default_pos([420.0, 300.0])
1499                    .default_size([560.0, 420.0])
1500                    .open(&mut open)
1501                    .show(ctx2, |ui| {
1502                        render_contracts_window(ui, shared);
1503                    });
1504                shared.set_contracts_window_open(open);
1505                !open
1506            },
1507            |ui| {
1508                render_contracts_window(ui, shared);
1509                false
1510            },
1511        );
1512        if closed {
1513            shared.set_contracts_window_open(false);
1514        }
1515    }
1516
1517    // K线窗(双击弹出,按合约多开;出 kline_symbols 列表 = 不再 show = 销毁)。
1518    // BD-UI-34:打开即最大化(maximized)+ ESC 键快捷关闭(body 请求关闭;
1519    // 键按子窗焦点路由,主窗按键不误关)。
1520    let mut closed: Vec<String> = Vec::new();
1521    for sym in shared.kline_symbols() {
1522        let is_closed = show_independent_window(
1523            ctx,
1524            format!("kline_win:{sym}"),
1525            &format!("K线 — {sym}"),
1526            &WinGeom {
1527                inner: [560.0, 360.0],
1528                min_inner: [360.0, 220.0],
1529                maximized: true,
1530            },
1531            |ctx2| {
1532                let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
1533                let resp = egui::Window::new(format!("K线 — {sym}"))
1534                    .id(egui::Id::new(("kline_win", sym.clone())))
1535                    .default_pos([300.0, 340.0])
1536                    .default_size([540.0, 330.0])
1537                    .show(ctx2, |ui| {
1538                        let cell = shared
1539                            .market()
1540                            .cells()
1541                            .into_iter()
1542                            .find(|c| c.symbol == sym);
1543                        render_kline_cell(ui, cell.as_ref());
1544                    });
1545                esc || resp.is_none()
1546            },
1547            |ui| {
1548                if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
1549                    return true;
1550                }
1551                let cell = shared
1552                    .market()
1553                    .cells()
1554                    .into_iter()
1555                    .find(|c| c.symbol == sym);
1556                render_kline_cell(ui, cell.as_ref());
1557                false
1558            },
1559        );
1560        if is_closed {
1561            closed.push(sym);
1562        }
1563    }
1564    for s in closed {
1565        shared.close_kline(&s);
1566    }
1567}
1568
1569/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
1570/// + 依赖图。回灌界面面已退役(总线口保留;流控查询钮 0.2.10 随 sim 退场撤除)。
1571fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1572    ui.label("插件");
1573    let snap = shared.snapshot();
1574    egui::Grid::new("plug_grid")
1575        .striped(true)
1576        .num_columns(6)
1577        .show(ui, |ui| {
1578            ui.strong("名称");
1579            ui.strong("状态");
1580            ui.strong("版本");
1581            ui.strong("依赖");
1582            ui.strong("统计");
1583            ui.strong("操作");
1584            ui.end_row();
1585            if snap.rows.is_empty() {
1586                ui.label("(快照空:等 eTimer 首拉)");
1587                ui.end_row();
1588            }
1589            for r in &snap.rows {
1590                if is_quarantined(&r.state) {
1591                    ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1592                    ui.label(
1593                        egui::RichText::new(state_text(r))
1594                            .color(egui::Color32::RED)
1595                            .strong(),
1596                    );
1597                } else {
1598                    ui.label(r.name.clone());
1599                    ui.label(state_text(r));
1600                }
1601                ui.label(r.version.clone());
1602                ui.label(format!("deps:[{}]", r.deps.join(",")));
1603                ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1604                ui.horizontal(|ui| {
1605                    if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1606                        shared.push_cmd(UiCmd::Start {
1607                            factory: r.name.clone(),
1608                        });
1609                    }
1610                    let armed = shared.pending_confirm();
1611                    let armed_here = ui
1612                        .small_button(remove_button_label(&r.name, armed.as_deref()))
1613                        .clicked()
1614                        && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1615                    if armed_here {
1616                        shared.push_cmd(UiCmd::Remove {
1617                            name: r.name.clone(),
1618                        });
1619                    }
1620                });
1621                ui.end_row();
1622            }
1623        });
1624
1625    if let Some(name) = shared.pending_confirm() {
1626        ui.horizontal(|ui| {
1627            ui.label(
1628                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1629                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1630            );
1631            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1632                shared.confirm_cancel();
1633            }
1634        });
1635    }
1636
1637    ui.label("服务");
1638    let flat: Vec<String> = snap
1639        .services
1640        .iter()
1641        .flat_map(|(n, ids)| {
1642            if ids.is_empty() {
1643                vec![format!("{n} 服务"), "-".to_string()]
1644            } else {
1645                ids.iter()
1646                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
1647                    .collect()
1648            }
1649        })
1650        .collect();
1651    if flat.is_empty() {
1652        ui.label("(无服务目录)");
1653    } else {
1654        let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
1655        egui::Grid::new("svc_grid")
1656            .striped(true)
1657            .num_columns(6)
1658            .show(ui, |ui| {
1659                for r in 0..rows {
1660                    for c in 0..3 {
1661                        let i = c * rows * 2 + r * 2;
1662                        if i < flat.len() {
1663                            ui.label(flat[i].clone());
1664                            ui.label(flat[i + 1].clone());
1665                        } else {
1666                            ui.label("");
1667                            ui.label("");
1668                        }
1669                    }
1670                    ui.end_row();
1671                }
1672            });
1673    }
1674
1675    ui.label("依赖");
1676    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1677        ui.label("(全部无依赖)");
1678    }
1679    for r in &snap.rows {
1680        if !r.deps.is_empty() {
1681            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1682        }
1683    }
1684}
1685
1686/// CTP 配置窗渲染(S6 定盘口径:账号/前置全部界面手工输入,插件不读文件;
1687/// `连接 CTP` → ask ctp.configure;密码位掩码。**无订阅字段**(用户裁定:
1688/// 订阅不在网关窗定死,走下单面板代码回车或查询合约窗勾选))。
1689fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1690    ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1691    let mut form: crate::state::CtpForm = shared.ctp_form();
1692    egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1693        ui.label("broker");
1694        ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1695        ui.label("td 前置");
1696        ctp_edit(
1697            ui,
1698            shared,
1699            &mut form.td_addr,
1700            "tcp://host:40001",
1701            CtpField::TdAddr,
1702        );
1703        ui.label("td 用户");
1704        ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1705        ui.end_row();
1706        ui.label("td 密码");
1707        ctp_edit(
1708            ui,
1709            shared,
1710            &mut form.td_password,
1711            "密码",
1712            CtpField::TdPassword,
1713        );
1714        ui.label("AppID");
1715        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1716        ui.label("AuthCode");
1717        ctp_edit(
1718            ui,
1719            shared,
1720            &mut form.td_auth_code,
1721            "授权码",
1722            CtpField::AuthCode,
1723        );
1724        ui.end_row();
1725        ui.label("md 前置");
1726        ctp_edit(
1727            ui,
1728            shared,
1729            &mut form.md_addr,
1730            "tcp://host:40011",
1731            CtpField::MdAddr,
1732        );
1733        ui.label("md 用户");
1734        ctp_edit(
1735            ui,
1736            shared,
1737            &mut form.md_user,
1738            "空=td 回落",
1739            CtpField::MdUser,
1740        );
1741        ui.label("md 密码");
1742        ctp_edit(
1743            ui,
1744            shared,
1745            &mut form.md_password,
1746            "空=td 回落",
1747            CtpField::MdPassword,
1748        );
1749        ui.end_row();
1750    });
1751    ui.horizontal(|ui| {
1752        if ui.small_button(BTN_CTP_CONNECT).clicked() {
1753            shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1754            shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1755        }
1756        ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1757    });
1758}
1759
1760/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
1761/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
1762/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
1763fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1764    let mut q = shared.contract_query();
1765    ui.horizontal(|ui| {
1766        ui.label("代码或交易所(留空 = 全部):");
1767        if ui
1768            .add(
1769                egui::TextEdit::singleline(&mut q)
1770                    .hint_text("cu 或 SHFE")
1771                    .desired_width(200.0),
1772            )
1773            .changed()
1774        {
1775            shared.set_contract_query(q.clone());
1776        }
1777    });
1778    let rows = shared.contracts(&q);
1779    // 表头单源(0.2.6 审计修:空态分支曾误写另一套表头「代码/…/最后价」,
1780    // 与非空分支「选(代码)/…/最小变动」不一致——列语义两处必须同源)。
1781    const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
1782    if rows.is_empty() {
1783        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径);动作行无意义可省。
1784        pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
1785            ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1786        });
1787        return;
1788    }
1789    let selected = shared.selected_contracts();
1790    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
1791    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
1792    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
1793    // 行溢出才出滚动条。
1794    ui.horizontal(|ui| {
1795        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1796        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1797            if selected.is_empty() {
1798                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1799            } else {
1800                // en_symbol 勾选集 → CTP 裸代码订阅(ask ctp.subscribe 收裸代码)
1801                let symbols: Vec<String> = selected
1802                    .iter()
1803                    .map(|k| {
1804                        k.split_once('.')
1805                            .map(|(_, s)| s.to_string())
1806                            .unwrap_or_else(|| k.clone())
1807                    })
1808                    .collect();
1809                shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1810                shared.log_line(format!(
1811                    "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1812                    selected.len()
1813                ));
1814            }
1815        }
1816    });
1817    pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
1818        for c in &rows {
1819            let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
1820            let mut on = selected.contains(&key);
1821            // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
1822            // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
1823            // 行选中(动作归行,不归某一列)。
1824            let row_sel = shared.row_selected("contracts", &key);
1825            let mut cells = Vec::with_capacity(5);
1826            let ck = ui.checkbox(&mut on, key.clone());
1827            if ck.changed() {
1828                shared.toggle_contract_selection(&key);
1829                shared.select_row("contracts", &key);
1830            }
1831            cells.push(ck.rect);
1832            macro_rules! cell {
1833                ($base:expr, $col:expr) => {{
1834                    let base = $base;
1835                    let r = click_cell(ui, base.rect, ("crt", &key, $col));
1836                    if r.clicked() {
1837                        shared.select_row("contracts", &key);
1838                    }
1839                    cells.push(base.rect);
1840                }};
1841            }
1842            cell!(ui.label(c.exchange.as_str().to_string()), 1);
1843            cell!(ui.label(c.name.clone()), 2);
1844            cell!(ui.label(format!("{:.0}", c.size)), 3);
1845            cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
1846            paint_row_bg(ui, &cells, row_sel);
1847            ui.end_row();
1848        }
1849    });
1850}
1851
1852/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
1853enum CtpField {
1854    Broker,
1855    TdAddr,
1856    TdUser,
1857    TdPassword,
1858    AppId,
1859    AuthCode,
1860    MdAddr,
1861    MdUser,
1862    MdPassword,
1863}
1864
1865/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
1866fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1867    let is_secret = matches!(
1868        field,
1869        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1870    );
1871    let mut w = egui::TextEdit::singleline(val)
1872        .hint_text(hint)
1873        .desired_width(110.0);
1874    if is_secret {
1875        w = w.password(true);
1876    }
1877    if ui.add(w).changed() {
1878        let mut f = shared.ctp_form();
1879        match field {
1880            CtpField::Broker => f.broker_id = val.clone(),
1881            CtpField::TdAddr => f.td_addr = val.clone(),
1882            CtpField::TdUser => f.td_user = val.clone(),
1883            CtpField::TdPassword => f.td_password = val.clone(),
1884            CtpField::AppId => f.td_app_id = val.clone(),
1885            CtpField::AuthCode => f.td_auth_code = val.clone(),
1886            CtpField::MdAddr => f.md_addr = val.clone(),
1887            CtpField::MdUser => f.md_user = val.clone(),
1888            CtpField::MdPassword => f.md_password = val.clone(),
1889        }
1890        shared.set_ctp_form(f);
1891    }
1892}
1893
1894/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
1895///
1896/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
1897fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1898    let Some(c) = cell else {
1899        ui.label("(K线无格:等 dm 合成)");
1900        return;
1901    };
1902    if c.bars.is_empty() {
1903        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1904        return;
1905    }
1906    let mut data = egui_charts::model::bar::BarData::new();
1907    for b in &c.bars {
1908        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
1909        data.push(egui_charts::model::bar::Bar::new(
1910            dt,
1911            b.open_price,
1912            b.high_price,
1913            b.low_price,
1914            b.close_price,
1915            b.volume,
1916        ));
1917    }
1918    ui.label(format!(
1919        "K线 {}@{}s({} 根)",
1920        c.symbol,
1921        c.interval,
1922        c.bars.len()
1923    ));
1924    let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1925    chart.set_symbol(&c.symbol);
1926    chart.set_timeframe_label(&format!("{}s", c.interval));
1927    chart.enable_tracking_mode();
1928    let _ = chart.show(ui);
1929}