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