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;v4 收纳进运维窗)。
50pub const BTN_QUERY_RATE: &str = "流控查询";
51/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
52pub const BTN_CANCEL_ORDER: &str = "撤单";
53/// CTP 连接按钮标签(S6;手工配置 → ask ctp.configure)。
54pub const BTN_CTP_CONNECT: &str = "连接 CTP";
55/// 菜单项:运维(弹运维窗)。
56pub const MENU_OPS: &str = "运维";
57/// 菜单项:网关(弹 CTP 配置窗)。
58pub const MENU_CTP: &str = "网关";
59/// 菜单项:帮助。
60pub const MENU_HELP: &str = "帮助";
61/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
62pub const MENU_CONTRACTS: &str = "查询合约";
63/// 菜单项 → 弹窗入口钮(BD-UI-31 一键直达:菜单钮即入口,一步弹窗,
64/// 不再挂下拉层;入口钮标签 = 菜单名本身)。
65/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
66pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
67
68fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
69    if armed_on == Some(name) {
70        format!("确认移除 {name} ?")
71    } else {
72        format!("移除 {name}")
73    }
74}
75
76fn state_text(r: &PlugRow) -> String {
77    r.state.clone()
78}
79
80/// DtNs → HH:MM:SS(UTC;表内时间列统一口径)。
81fn fmt_dt(dt: Option<i64>) -> String {
82    dt.map(|ns| {
83        chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
84            .format("%H:%M:%S")
85            .to_string()
86    })
87    .unwrap_or_else(|| "-".to_string())
88}
89
90// ---- BD-UI-31(R3)行高亮:悬停淡底 / 选中跳底 ----
91
92/// 行底色绘制:行内各格响应矩形并集 → 选中 = 跳底(蓝调);否则悬停 = 淡灰底。
93/// 半透明叠底(行文字先画,叠后仍可读);行交互钮单击选中与双击动作并存
94/// (egui 双击先经 clicked,选中顺带发生,无冲突)。
95fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
96    let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
97        return;
98    };
99    let hovered = ui
100        .input(|i| i.pointer.interact_pos())
101        .is_some_and(|p| union.expand(3.0).contains(p));
102    if selected {
103        ui.painter().rect_filled(
104            union.expand(3.0),
105            3.0,
106            egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
107        );
108    } else if hovered {
109        ui.painter().rect_filled(
110            union.expand(3.0),
111            3.0,
112            egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
113        );
114    }
115}
116
117/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
118fn order_active(status: ek_data::constant::Status) -> bool {
119    ek_data::constant::ACTIVE_STATUSES.contains(&status)
120}
121
122/// 格可点化(BD-UI-31 扩展):标签外观不变,在标签矩形上叠一层点击感应 ——
123/// 行内任何一格都可单击/双击(用户裁定:动作归行,不归某一列)。
124fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
125    ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
126}
127
128/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
129/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
130/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
131pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
132    let mut f = shared.order_form();
133    f.symbol = t.symbol.clone();
134    f.exchange = exchange.to_string();
135    match col {
136        8 => {
137            f.direction = Direction::SHORT;
138            f.price = format!("{:.1}", t.bid_price_1);
139        }
140        9 => f.direction = Direction::SHORT,
141        10 => {
142            f.direction = Direction::LONG;
143            f.price = format!("{:.1}", t.ask_price_1);
144        }
145        11 => f.direction = Direction::LONG,
146        _ => {}
147    }
148    shared.set_order_form(f);
149}
150
151/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
152/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
153fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
154    if symbol.is_empty() {
155        return None;
156    }
157    shared.tick_rows().into_iter().find(|t| {
158        t.symbol == symbol
159            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
160    })
161}
162
163/// 全局字号(BD-UI-32 R1,用户实看裁定「整体字偏小」,对照 Python 版):
164/// body/button/strong 16px、small 14px、heading 22px、monospace 15px
165/// (egui 缺省 13/12/18/12)。render_body 顶部统一施加,真实 App 与 kittest 同源。
166pub fn apply_ui_style(ctx: &egui::Context) {
167    apply_ui_style_for(ctx, crate::state::Theme::default());
168}
169
170/// 字号 + 主题视觉(BD-UI-34,用户问「PySide6 式可换主题」):主题 = UiShared
171/// 运行期态(菜单钮一键循环),视觉在此统一施加——真实 App、独立子窗
172/// (immediate 共享同一 Context)、kittest 三处同源。语义色(多红/空绿/
173/// 隔离红/选中蓝)不在此列:渲染侧恒定直绘,跨主题不漂移(金融语义)。
174pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
175    ctx.style_mut(|style| {
176        style
177            .text_styles
178            .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
179        style
180            .text_styles
181            .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
182        style
183            .text_styles
184            .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
185        style
186            .text_styles
187            .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
188        style
189            .text_styles
190            .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
191    });
192    ctx.set_visuals(theme_visuals(theme));
193}
194
195/// 四套主题视觉(BD-UI-34)。金融深色 = egui dark 骨架换深海军蓝底
196/// (交易终端观感,对照 Python 版深色窗);Qt 灰白 = PySide6 Fusion 迁移用。
197fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
198    use egui::Color32;
199    match t {
200        crate::state::Theme::EguiDark => egui::Visuals::dark(),
201        crate::state::Theme::EguiLight => egui::Visuals::light(),
202        crate::state::Theme::QtClassic => {
203            let mut v = egui::Visuals::light();
204            v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
205            v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
206            v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
207            v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
208            v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
209            v
210        }
211        crate::state::Theme::FinanceDark => {
212            let mut v = egui::Visuals::dark();
213            v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
214            v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
215            v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
216            v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
217            v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
218            v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
219            v.widgets.noninteractive.bg_stroke =
220                egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
221            v
222        }
223    }
224}
225
226/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
227///
228/// v5 框架(BD-UI-29 面板化):TopBottomPanel(菜单) → TopBottomPanel(行情带,
229/// resizable = Python 分带 splitter 语义) → SidePanel(下单面板 + 盘口,
230/// resizable) → CentralPanel(2×2 监控格,响应式分配,必须最后) → 弹窗。
231/// 官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、CentralPanel 最后加、
232/// Window 挂在所有面板之后。
233pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
234    apply_ui_style_for(ctx, shared.theme());
235    // ---- 菜单栏(顶层面板,最先 = 最外) ----
236    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
237        render_menu_bar(ui, shared);
238    });
239
240    // ---- 上带:行情带(全宽独占;可拖分隔条,缺省 240 ≈ Python 30:70 分带) ----
241    egui::TopBottomPanel::top("market_band")
242        .resizable(true)
243        .default_height(240.0)
244        .show(ctx, |ui| {
245            // 外层 ScrollArea auto_shrink(false) = resizable 面板吃满前提(BD-UI-27
246            // 口径;s3 实锤:撤掉后空态带塌成一行,2×2 行位全漂移)。内层
247            // pinned_table 自己会收缩适配 → 外层滚动条永不出现。
248            egui::ScrollArea::both()
249                .id_salt("market_band_scroll")
250                .auto_shrink([false, false])
251                .show(ui, |ui| {
252                    render_market_band(ui, shared);
253                });
254        });
255
256    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget 内序:字段钮 → 盘口) ----
257    egui::SidePanel::left("order_side")
258        .resizable(true)
259        .default_width(250.0)
260        .show(ctx, |ui| {
261            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
262            egui::ScrollArea::vertical()
263                .id_salt("order_side_scroll")
264                .auto_shrink([false, false])
265                .show(ui, |ui| {
266                    render_order_panel(ui, shared);
267                    ui.separator();
268                    render_book_frame(ui, shared);
269                });
270        });
271
272    // ---- 下带右侧:2×2 监控格(CentralPanel 最后;响应式分配,无定宽定高) ----
273    egui::CentralPanel::default().show(ctx, |ui| {
274        let avail = ui.available_size();
275        let sp = ui.spacing().item_spacing;
276        // 机制钉(dbg 实锤):horizontal 行成员的可用高度初始只有一行高
277        // (interact_size),高度分配不沿横向向下传播 → 定高 max_height 失效、
278        // 行按内容收缩(v4 死区根因)。官方解法 = 先精确预留矩形(group_frame:
279        // allocate_exact_size + new_child;add_sized 收 impl Widget 不适用),
280        // Frame+ScrollArea 在预留矩形内才吃满。
281        let gw = ((avail.x - sp.x) / 2.0).max(220.0);
282        let gh = ((avail.y - sp.y) / 2.0).max(120.0);
283        ui.horizontal(|ui| {
284            group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
285                render_topleft_group(ui, shared);
286            });
287            group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
288                render_trades_group(ui, shared);
289            });
290        });
291        ui.horizontal(|ui| {
292            group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
293                render_positions_group(ui, shared);
294            });
295            // 资金/日志格:保留带填充 ScrollArea 的容器(0.2.6 钉):此格无
296            // pinned_table,plain Frame 会收缩到内容宽(实测 810..1007)→ 右边框
297            // 落在行中段 = 又一根「多出来的竖线」。外层 ScrollArea
298            // auto_shrink(false) 让 Frame 吃满 gw,边界回到格位。
299            group_frame(ui, "grp_bottomright", gw, gh, |ui| {
300                render_bottomright_group(ui, shared);
301            });
302        });
303    });
304
305    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
306    render_windows(ctx, shared);
307}
308
309/// 2×2 格统一容器(响应式尺寸 + 双向滚动,BD-UI-27 可达性口径延续)。
310/// 尺寸钉(BD-UI-29):`allocate_exact_size` 精确预留 w×h 矩形 + `new_child`
311/// 在预留矩形内开 Frame+ScrollArea(dbg 实锤:horizontal 行成员的可用高度
312/// 初始只有一行高,高度分配不沿横向向下传播 → 定高 max_height 失效、行按
313/// 内容收缩 = v4 死区根因;先预留矩形,可用空间才吃满)。
314/// 布局泄漏钉(第五轮审计):Frame::show 继承父级布局 → 格内内容强制
315/// `ui.vertical`(短行成行,宽网格末位溢出走横向滚动)。
316fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
317    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
318    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
319    egui::Frame::group(ui.style()).show(&mut child, |ui| {
320        egui::ScrollArea::both()
321            .id_salt(id)
322            .auto_shrink([false, false])
323            .show(ui, |ui| {
324                ui.vertical(content);
325            });
326    });
327}
328
329/// 表格格容器(BD-UI-34):预留矩形 + Frame,但**不开外层 ScrollArea**——
330/// 滚动归 pinned_table 自己(表头固定 + 体滚 + 横滚同移),外层再包滚动
331/// = 双滚动条且表头失去固定意义。
332/// 吃满钉(0.2.6):Frame::show 的描边矩形按内容 min_rect 收缩(dbg 实锤:
333/// 内容换成一行 label 时 Frame 从 gw 宽缩到 112)——本容器只配 pinned_table
334/// (auto_shrink(false) 滚动区 = 不收缩填充物)使用;非表格内容走 group_frame。
335fn group_frame_plain(
336    ui: &mut egui::Ui,
337    _id: &str,
338    w: f32,
339    h: f32,
340    content: impl FnOnce(&mut egui::Ui),
341) {
342    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
343    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
344    egui::Frame::group(ui.style()).show(&mut child, |ui| {
345        ui.vertical(content);
346    });
347}
348
349/// 表头固定表(BD-UI-34,用户裁定「每个表的表头都要固定」):
350/// - 单 grid 实例(头行 + 体行)在 both ScrollArea 内——列对齐天然、无同 id
351///   双 grid 病(dbg 实锤:两 grid 同 id 各从空白重建 State,save 交替
352///   request_repaint → kittest max_steps 爆)。
353/// - 表头固定 = 官方 DIY 模式(scroll_area.rs:412「painting a sticky header
354///   on top of it」):ScrollArea 之后,用本拍捕获的表头格矩形在滚动区顶部
355///   重画一条表头带(背景盖行 + 文字重画)。格矩形已含滚动平移 → 横向自动
356///   跟随、纵向永远盖顶,零拍滞后。带高 = 表头格高 + 行间距(0.2.6 修:只盖
357///   格高时,滚动行从行间距带里露 3px = 用户报「表头缝露内容」)。
358/// - 覆盖带 = 纯 painter 绘制(0.2.6 修,用户报「资金右多一根竖线」根因):
359///   旧版 ui.put 重画视口外表头格(16 列总宽撑到 x=1222),布局 min_rect
360///   被撑宽 → Frame 描边矩形跟着撑 → 格右边界画在内容中段。painter.text/
361///   rect_filled 零布局副作用;越界文字由 clip 裁掉。
362///
363/// body 闭包在体表 grid 内执行(行循环 + end_row 归闭包)。
364fn pinned_table(
365    ui: &mut egui::Ui,
366    grid_id: &str,
367    num_columns: usize,
368    headers: &[&str],
369    body: impl FnOnce(&mut egui::Ui),
370) {
371    let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
372        std::cell::RefCell::new(Vec::new());
373    let resp = egui::ScrollArea::both()
374        .id_salt(format!("{grid_id}_sc"))
375        .auto_shrink([false, false])
376        .show(ui, |ui| {
377            egui::Grid::new(grid_id)
378                .striped(true)
379                .num_columns(num_columns)
380                .show(ui, |ui| {
381                    for h in headers {
382                        let r = ui.strong(*h);
383                        heads_cell.borrow_mut().push((r.rect, h.to_string()));
384                    }
385                    ui.end_row();
386                    body(ui);
387                });
388        });
389    let heads = heads_cell.borrow();
390    if heads.is_empty() {
391        return;
392    }
393    let area = resp.inner_rect;
394    let head_h = heads[0].0.height();
395    // 覆盖带几何(0.2.8,BD-UI-35 缝隙根因):ScrollArea 内容裁剪 = inner_rect
396    // 向外扩 visuals().clip_rect_margin(默认 3.0,style.rs:1435;
397    // scroll_area.rs:771-772)——带子若只从 inner_rect 顶边起画,滚动行会从
398    // 带子上方 3px 余量里露出(用户报「表头上面有缝」)。带子向上/左右各扩
399    // 该余量,向下盖 表头格高 + grid 行间距。clip 同步 = 带矩形。
400    let m = ui.visuals().clip_rect_margin;
401    let strip = egui::Rect::from_min_max(
402        egui::pos2(area.min.x - m, area.min.y - m),
403        egui::pos2(
404            area.max.x + m,
405            area.min.y + head_h + ui.spacing().item_spacing.y,
406        ),
407    );
408    ui.scope_builder(
409        egui::UiBuilder::new()
410            .id_salt(format!("{grid_id}_hdr_ov"))
411            .max_rect(strip),
412        |ui| {
413            ui.set_clip_rect(strip);
414            let painter = ui.painter();
415            painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
416            let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
417            let color = ui.visuals().strong_text_color();
418            for (rect, text) in heads.iter() {
419                painter.text(
420                    egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
421                    egui::Align2::LEFT_CENTER,
422                    text.as_str(),
423                    font.clone(),
424                    color,
425                );
426            }
427        },
428    );
429}
430
431// ================= 菜单栏 =================
432
433fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
434    ui.horizontal(|ui| {
435        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
436        if ui.button(MENU_OPS).clicked() {
437            shared.set_ops_window_open(true);
438        }
439        if ui.button(MENU_CTP).clicked() {
440            shared.set_ctp_window_open(true);
441        }
442        if ui.button(MENU_CONTRACTS).clicked() {
443            shared.set_contracts_window_open(true);
444        }
445        ui.menu_button(MENU_HELP, |ui| {
446            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
447            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
448        });
449        // BD-UI-34:主题一键循环(金融深色→egui深色→egui浅色→Qt灰白→…;
450        // 单层钮无下拉,与 R1「菜单钮即入口」同口径)。
451        if ui
452            .button(format!("主题:{}", shared.theme().label()))
453            .clicked()
454        {
455            let t = shared.cycle_theme();
456            shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
457        }
458    });
459}
460
461// ================= 行情带 =================
462
463fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
464    // BD-UI-31(R4):表题文字撤除(带位即身份,Python TickMonitor 无题同口径)。
465    let groups = shared.tick_groups();
466    if groups.is_empty() {
467        ui.label("(等待 eTick 订阅)");
468        return;
469    }
470    // 二级 tab:只列有 tick 的所
471    let sel = shared.selected_exchange();
472    let active = if groups.iter().any(|(e, _)| *e == sel) {
473        sel.clone()
474    } else {
475        groups[0].0.clone()
476    };
477    ui.horizontal(|ui| {
478        for (ex, _) in &groups {
479            if ui.selectable_label(*ex == active, ex.clone()).clicked() {
480                shared.set_selected_exchange(ex.clone());
481            }
482        }
483    });
484    let ticks = groups
485        .iter()
486        .find(|(e, _)| *e == active)
487        .map(|(_, v)| v.clone())
488        .unwrap_or_default();
489    // 状态行「tick N 格 / 最后更新 …」撤除(BD-UI-35,用户裁定:此信息早已
490    // 不需要,行情带 tab + 表格即全部)。
491    pinned_table(
492        ui,
493        "tick_grid",
494        14,
495        &[
496            "代码",
497            "名称",
498            "最新价",
499            "涨跌幅",
500            "成交量",
501            "今开",
502            "最高",
503            "最低",
504            "买1价",
505            "买1量",
506            "卖1价",
507            "卖1量",
508            "时间",
509            "接口",
510        ],
511        |ui| {
512            for t in &ticks {
513                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
514                // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
515                // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
516                // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
517                let row_sel = shared.order_form().symbol == t.symbol;
518                let mut cells = Vec::with_capacity(14);
519                let mut dbl = false;
520                let resp = ui.button(t.symbol.clone());
521                if resp.clicked() {
522                    shared.set_selected_exchange(active.clone());
523                    import_tick(shared, t, &active, 0);
524                }
525                dbl |= resp.double_clicked();
526                cells.push(resp.rect);
527                macro_rules! cell {
528                    ($base:expr, $col:expr) => {{
529                        let base = $base;
530                        let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
531                        if r.clicked() {
532                            shared.set_selected_exchange(active.clone());
533                            import_tick(shared, t, &active, $col);
534                        }
535                        dbl |= r.double_clicked();
536                        cells.push(base.rect);
537                    }};
538                }
539                cell!(
540                    ui.label(if t.name.is_empty() {
541                        "-".to_string()
542                    } else {
543                        t.name.clone()
544                    }),
545                    1
546                );
547                cell!(ui.label(format!("{:.1}", t.last_price)), 2);
548                cell!(
549                    ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
550                        if t.pct_change < 0.0 {
551                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
552                        } else {
553                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
554                        },
555                    )),
556                    3
557                );
558                cell!(ui.label(format!("{:.0}", t.volume)), 4);
559                cell!(ui.label(format!("{:.1}", t.open_price)), 5);
560                cell!(ui.label(format!("{:.1}", t.high_price)), 6);
561                cell!(ui.label(format!("{:.1}", t.low_price)), 7);
562                cell!(
563                    ui.label(
564                        egui::RichText::new(format!("{:.1}", t.bid_price_1))
565                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
566                    ),
567                    8
568                );
569                cell!(
570                    ui.label(
571                        egui::RichText::new(format!("{:.0}", t.bid_volume_1))
572                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
573                    ),
574                    9
575                );
576                cell!(
577                    ui.label(
578                        egui::RichText::new(format!("{:.1}", t.ask_price_1))
579                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
580                    ),
581                    10
582                );
583                cell!(
584                    ui.label(
585                        egui::RichText::new(format!("{:.0}", t.ask_volume_1))
586                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
587                    ),
588                    11
589                );
590                cell!(
591                    ui.label(if t.update_time.is_empty() {
592                        fmt_dt(Some(t.datetime))
593                    } else {
594                        t.update_time.clone()
595                    }),
596                    12
597                );
598                cell!(ui.label(t.gateway_name.clone()), 13);
599                if dbl {
600                    shared.open_kline(t.symbol.clone());
601                }
602                paint_row_bg(ui, &cells, row_sel);
603                ui.end_row();
604            }
605        },
606    );
607}
608
609// ================= 下单面板(常驻) =================
610
611fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
612    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
613    let mut f = shared.order_form();
614    let before = f.clone();
615    let mut err: Option<String> = None;
616
617    // 随价勾选:价格框随该合约最新 tick(勾选态)
618    if f.follow_price {
619        if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
620            if t.last_price > 0.0 {
621                f.price = format!("{:.1}", t.last_price);
622            }
623        }
624    }
625
626    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
627    let mut ex_opts: Vec<String> = shared
628        .tick_groups()
629        .iter()
630        .map(|(e, _)| e.clone())
631        .collect();
632    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
633        if !ex_opts.iter().any(|x| x == e) {
634            ex_opts.push(e.to_string());
635        }
636    }
637    if !ex_opts.contains(&f.exchange) {
638        ex_opts.insert(0, f.exchange.clone());
639    }
640    // 接口下拉:trade_snap 网关去重集 ∪ {sim, ctp}(审计钉:不新增总线口)
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 ["sim", "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                    "sim".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                            "sim".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                        "sim".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;空串兜底 sim)
1062                        let gw = if o.gateway_name.is_empty() {
1063                            "sim".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/// + 依赖图 + 流控查询钮。回灌界面面已退役(总线口保留)。
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    ui.horizontal(|ui| {
1686        if ui.small_button(BTN_QUERY_RATE).clicked() {
1687            shared.push_cmd(UiCmd::QueryRate);
1688        }
1689    });
1690}
1691
1692/// CTP 配置窗渲染(S6 定盘口径:账号/前置全部界面手工输入,插件不读文件;
1693/// `连接 CTP` → ask ctp.configure;密码位掩码。**无订阅字段**(用户裁定:
1694/// 订阅不在网关窗定死,走下单面板代码回车或查询合约窗勾选))。
1695fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1696    ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1697    let mut form: crate::state::CtpForm = shared.ctp_form();
1698    egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1699        ui.label("broker");
1700        ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1701        ui.label("td 前置");
1702        ctp_edit(
1703            ui,
1704            shared,
1705            &mut form.td_addr,
1706            "tcp://host:40001",
1707            CtpField::TdAddr,
1708        );
1709        ui.label("td 用户");
1710        ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1711        ui.end_row();
1712        ui.label("td 密码");
1713        ctp_edit(
1714            ui,
1715            shared,
1716            &mut form.td_password,
1717            "密码",
1718            CtpField::TdPassword,
1719        );
1720        ui.label("AppID");
1721        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1722        ui.label("AuthCode");
1723        ctp_edit(
1724            ui,
1725            shared,
1726            &mut form.td_auth_code,
1727            "授权码",
1728            CtpField::AuthCode,
1729        );
1730        ui.end_row();
1731        ui.label("md 前置");
1732        ctp_edit(
1733            ui,
1734            shared,
1735            &mut form.md_addr,
1736            "tcp://host:40011",
1737            CtpField::MdAddr,
1738        );
1739        ui.label("md 用户");
1740        ctp_edit(
1741            ui,
1742            shared,
1743            &mut form.md_user,
1744            "空=td 回落",
1745            CtpField::MdUser,
1746        );
1747        ui.label("md 密码");
1748        ctp_edit(
1749            ui,
1750            shared,
1751            &mut form.md_password,
1752            "空=td 回落",
1753            CtpField::MdPassword,
1754        );
1755        ui.end_row();
1756    });
1757    ui.horizontal(|ui| {
1758        if ui.small_button(BTN_CTP_CONNECT).clicked() {
1759            shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1760            shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1761        }
1762        ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1763    });
1764}
1765
1766/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
1767/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
1768/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
1769fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1770    let mut q = shared.contract_query();
1771    ui.horizontal(|ui| {
1772        ui.label("代码或交易所(留空 = 全部):");
1773        if ui
1774            .add(
1775                egui::TextEdit::singleline(&mut q)
1776                    .hint_text("cu 或 SHFE")
1777                    .desired_width(200.0),
1778            )
1779            .changed()
1780        {
1781            shared.set_contract_query(q.clone());
1782        }
1783    });
1784    let rows = shared.contracts(&q);
1785    // 表头单源(0.2.6 审计修:空态分支曾误写另一套表头「代码/…/最后价」,
1786    // 与非空分支「选(代码)/…/最小变动」不一致——列语义两处必须同源)。
1787    const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
1788    if rows.is_empty() {
1789        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径);动作行无意义可省。
1790        pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
1791            ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1792        });
1793        return;
1794    }
1795    let selected = shared.selected_contracts();
1796    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
1797    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
1798    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
1799    // 行溢出才出滚动条。
1800    ui.horizontal(|ui| {
1801        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1802        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1803            if selected.is_empty() {
1804                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1805            } else {
1806                // en_symbol 勾选集 → CTP 裸代码订阅(ask ctp.subscribe 收裸代码)
1807                let symbols: Vec<String> = selected
1808                    .iter()
1809                    .map(|k| {
1810                        k.split_once('.')
1811                            .map(|(_, s)| s.to_string())
1812                            .unwrap_or_else(|| k.clone())
1813                    })
1814                    .collect();
1815                shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1816                shared.log_line(format!(
1817                    "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1818                    selected.len()
1819                ));
1820            }
1821        }
1822    });
1823    pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
1824        for c in &rows {
1825            let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
1826            let mut on = selected.contains(&key);
1827            // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
1828            // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
1829            // 行选中(动作归行,不归某一列)。
1830            let row_sel = shared.row_selected("contracts", &key);
1831            let mut cells = Vec::with_capacity(5);
1832            let ck = ui.checkbox(&mut on, key.clone());
1833            if ck.changed() {
1834                shared.toggle_contract_selection(&key);
1835                shared.select_row("contracts", &key);
1836            }
1837            cells.push(ck.rect);
1838            macro_rules! cell {
1839                ($base:expr, $col:expr) => {{
1840                    let base = $base;
1841                    let r = click_cell(ui, base.rect, ("crt", &key, $col));
1842                    if r.clicked() {
1843                        shared.select_row("contracts", &key);
1844                    }
1845                    cells.push(base.rect);
1846                }};
1847            }
1848            cell!(ui.label(c.exchange.as_str().to_string()), 1);
1849            cell!(ui.label(c.name.clone()), 2);
1850            cell!(ui.label(format!("{:.0}", c.size)), 3);
1851            cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
1852            paint_row_bg(ui, &cells, row_sel);
1853            ui.end_row();
1854        }
1855    });
1856}
1857
1858/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
1859enum CtpField {
1860    Broker,
1861    TdAddr,
1862    TdUser,
1863    TdPassword,
1864    AppId,
1865    AuthCode,
1866    MdAddr,
1867    MdUser,
1868    MdPassword,
1869}
1870
1871/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
1872fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1873    let is_secret = matches!(
1874        field,
1875        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1876    );
1877    let mut w = egui::TextEdit::singleline(val)
1878        .hint_text(hint)
1879        .desired_width(110.0);
1880    if is_secret {
1881        w = w.password(true);
1882    }
1883    if ui.add(w).changed() {
1884        let mut f = shared.ctp_form();
1885        match field {
1886            CtpField::Broker => f.broker_id = val.clone(),
1887            CtpField::TdAddr => f.td_addr = val.clone(),
1888            CtpField::TdUser => f.td_user = val.clone(),
1889            CtpField::TdPassword => f.td_password = val.clone(),
1890            CtpField::AppId => f.td_app_id = val.clone(),
1891            CtpField::AuthCode => f.td_auth_code = val.clone(),
1892            CtpField::MdAddr => f.md_addr = val.clone(),
1893            CtpField::MdUser => f.md_user = val.clone(),
1894            CtpField::MdPassword => f.md_password = val.clone(),
1895        }
1896        shared.set_ctp_form(f);
1897    }
1898}
1899
1900/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
1901///
1902/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
1903fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1904    let Some(c) = cell else {
1905        ui.label("(K线无格:等 dm 合成)");
1906        return;
1907    };
1908    if c.bars.is_empty() {
1909        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1910        return;
1911    }
1912    let mut data = egui_charts::model::bar::BarData::new();
1913    for b in &c.bars {
1914        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
1915        data.push(egui_charts::model::bar::Bar::new(
1916            dt,
1917            b.open_price,
1918            b.high_price,
1919            b.low_price,
1920            b.close_price,
1921            b.volume,
1922        ));
1923    }
1924    ui.label(format!(
1925        "K线 {}@{}s({} 根)",
1926        c.symbol,
1927        c.interval,
1928        c.bars.len()
1929    ));
1930    let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1931    chart.set_symbol(&c.symbol);
1932    chart.set_timeframe_label(&format!("{}s", c.interval));
1933    chart.enable_tracking_mode();
1934    let _ = chart.show(ui);
1935}