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    let strip_h = head_h + ui.spacing().item_spacing.y;
396    let strip = egui::Rect::from_min_size(area.min, egui::vec2(area.width(), strip_h));
397    ui.scope_builder(
398        egui::UiBuilder::new()
399            .id_salt(format!("{grid_id}_hdr_ov"))
400            .max_rect(strip),
401        |ui| {
402            ui.set_clip_rect(area);
403            let painter = ui.painter();
404            painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
405            let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
406            let color = ui.visuals().strong_text_color();
407            for (rect, text) in heads.iter() {
408                painter.text(
409                    egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
410                    egui::Align2::LEFT_CENTER,
411                    text.as_str(),
412                    font.clone(),
413                    color,
414                );
415            }
416        },
417    );
418}
419
420// ================= 菜单栏 =================
421
422fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
423    ui.horizontal(|ui| {
424        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
425        if ui.button(MENU_OPS).clicked() {
426            shared.set_ops_window_open(true);
427        }
428        if ui.button(MENU_CTP).clicked() {
429            shared.set_ctp_window_open(true);
430        }
431        if ui.button(MENU_CONTRACTS).clicked() {
432            shared.set_contracts_window_open(true);
433        }
434        ui.menu_button(MENU_HELP, |ui| {
435            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
436            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
437        });
438        // BD-UI-34:主题一键循环(金融深色→egui深色→egui浅色→Qt灰白→…;
439        // 单层钮无下拉,与 R1「菜单钮即入口」同口径)。
440        if ui
441            .button(format!("主题:{}", shared.theme().label()))
442            .clicked()
443        {
444            let t = shared.cycle_theme();
445            shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
446        }
447    });
448}
449
450// ================= 行情带 =================
451
452fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
453    // BD-UI-31(R4):表题文字撤除(带位即身份,Python TickMonitor 无题同口径)。
454    let groups = shared.tick_groups();
455    if groups.is_empty() {
456        ui.label("(等待 eTick 订阅)");
457        return;
458    }
459    // 二级 tab:只列有 tick 的所
460    let sel = shared.selected_exchange();
461    let active = if groups.iter().any(|(e, _)| *e == sel) {
462        sel.clone()
463    } else {
464        groups[0].0.clone()
465    };
466    ui.horizontal(|ui| {
467        for (ex, _) in &groups {
468            if ui.selectable_label(*ex == active, ex.clone()).clicked() {
469                shared.set_selected_exchange(ex.clone());
470            }
471        }
472    });
473    let ticks = groups
474        .iter()
475        .find(|(e, _)| *e == active)
476        .map(|(_, v)| v.clone())
477        .unwrap_or_default();
478    let last_time = ticks
479        .iter()
480        .map(|t| {
481            if t.update_time.is_empty() {
482                fmt_dt(Some(t.datetime))
483            } else {
484                t.update_time.clone()
485            }
486        })
487        .max()
488        .unwrap_or_else(|| "-".to_string());
489    ui.label(format!(
490        "tick {} 格 / 最后更新 {last_time}",
491        shared.tick_count()
492    ));
493    pinned_table(
494        ui,
495        "tick_grid",
496        14,
497        &[
498            "代码",
499            "名称",
500            "最新价",
501            "涨跌幅",
502            "成交量",
503            "今开",
504            "最高",
505            "最低",
506            "买1价",
507            "买1量",
508            "卖1价",
509            "卖1量",
510            "时间",
511            "接口",
512        ],
513        |ui| {
514            for t in &ticks {
515                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
516                // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
517                // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
518                // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
519                let row_sel = shared.order_form().symbol == t.symbol;
520                let mut cells = Vec::with_capacity(14);
521                let mut dbl = false;
522                let resp = ui.button(t.symbol.clone());
523                if resp.clicked() {
524                    shared.set_selected_exchange(active.clone());
525                    import_tick(shared, t, &active, 0);
526                }
527                dbl |= resp.double_clicked();
528                cells.push(resp.rect);
529                macro_rules! cell {
530                    ($base:expr, $col:expr) => {{
531                        let base = $base;
532                        let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
533                        if r.clicked() {
534                            shared.set_selected_exchange(active.clone());
535                            import_tick(shared, t, &active, $col);
536                        }
537                        dbl |= r.double_clicked();
538                        cells.push(base.rect);
539                    }};
540                }
541                cell!(
542                    ui.label(if t.name.is_empty() {
543                        "-".to_string()
544                    } else {
545                        t.name.clone()
546                    }),
547                    1
548                );
549                cell!(ui.label(format!("{:.1}", t.last_price)), 2);
550                cell!(
551                    ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
552                        if t.pct_change < 0.0 {
553                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
554                        } else {
555                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
556                        },
557                    )),
558                    3
559                );
560                cell!(ui.label(format!("{:.0}", t.volume)), 4);
561                cell!(ui.label(format!("{:.1}", t.open_price)), 5);
562                cell!(ui.label(format!("{:.1}", t.high_price)), 6);
563                cell!(ui.label(format!("{:.1}", t.low_price)), 7);
564                cell!(
565                    ui.label(
566                        egui::RichText::new(format!("{:.1}", t.bid_price_1))
567                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
568                    ),
569                    8
570                );
571                cell!(
572                    ui.label(
573                        egui::RichText::new(format!("{:.0}", t.bid_volume_1))
574                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
575                    ),
576                    9
577                );
578                cell!(
579                    ui.label(
580                        egui::RichText::new(format!("{:.1}", t.ask_price_1))
581                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
582                    ),
583                    10
584                );
585                cell!(
586                    ui.label(
587                        egui::RichText::new(format!("{:.0}", t.ask_volume_1))
588                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
589                    ),
590                    11
591                );
592                cell!(
593                    ui.label(if t.update_time.is_empty() {
594                        fmt_dt(Some(t.datetime))
595                    } else {
596                        t.update_time.clone()
597                    }),
598                    12
599                );
600                cell!(ui.label(t.gateway_name.clone()), 13);
601                if dbl {
602                    shared.open_kline(t.symbol.clone());
603                }
604                paint_row_bg(ui, &cells, row_sel);
605                ui.end_row();
606            }
607        },
608    );
609}
610
611// ================= 下单面板(常驻) =================
612
613fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
614    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
615    let mut f = shared.order_form();
616    let before = f.clone();
617    let mut err: Option<String> = None;
618
619    // 随价勾选:价格框随该合约最新 tick(勾选态)
620    if f.follow_price {
621        if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
622            if t.last_price > 0.0 {
623                f.price = format!("{:.1}", t.last_price);
624            }
625        }
626    }
627
628    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
629    let mut ex_opts: Vec<String> = shared
630        .tick_groups()
631        .iter()
632        .map(|(e, _)| e.clone())
633        .collect();
634    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
635        if !ex_opts.iter().any(|x| x == e) {
636            ex_opts.push(e.to_string());
637        }
638    }
639    if !ex_opts.contains(&f.exchange) {
640        ex_opts.insert(0, f.exchange.clone());
641    }
642    // 接口下拉:trade_snap 网关去重集 ∪ {sim, ctp}(审计钉:不新增总线口)
643    let ts = shared.trade_snap();
644    let mut gw_opts: Vec<String> = Vec::new();
645    for g in ts
646        .orders
647        .iter()
648        .map(|o| o.gateway_name.clone())
649        .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
650        .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
651    {
652        if !g.is_empty() && !gw_opts.contains(&g) {
653            gw_opts.push(g);
654        }
655    }
656    for g in ["sim", "ctp"] {
657        if !gw_opts.iter().any(|x| x == g) {
658            gw_opts.push(g.to_string());
659        }
660    }
661    if !gw_opts.contains(&f.gateway) {
662        gw_opts.insert(0, f.gateway.clone());
663    }
664
665    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
666    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
667    egui::Grid::new("order_form_grid")
668        .num_columns(2)
669        .show(ui, |ui| {
670            ui.label("交易所");
671            egui::ComboBox::from_id_salt("of_exchange")
672                .selected_text(f.exchange.clone())
673                .show_ui(ui, |ui| {
674                    for e in &ex_opts {
675                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
676                    }
677                });
678            ui.end_row();
679            ui.label("代码");
680            let sym_resp = ui.add(
681                egui::TextEdit::singleline(&mut f.symbol)
682                    .hint_text("cu2709")
683                    .desired_width(150.0),
684            );
685            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
686            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
687            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
688            // has_focus 口径回车订阅从不触发)。
689            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
690                let s = f.symbol.trim().to_string();
691                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
692                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
693                let bare = match s.split_once('.') {
694                    Some((_, b)) => b.trim().to_string(),
695                    None => s,
696                };
697                if !bare.is_empty() {
698                    f.symbol = bare.clone();
699                    shared.push_cmd(UiCmd::CtpSubscribe {
700                        symbols: vec![bare.clone()],
701                    });
702                    shared.log_line(format!("[ops] 订阅已入列 {bare}(代码回车订阅)"));
703                }
704            }
705            ui.end_row();
706            ui.label("名称");
707            ui.label(
708                tick_of(shared, &f.exchange, &f.symbol)
709                    .map(|t| {
710                        if t.name.is_empty() {
711                            "-".to_string()
712                        } else {
713                            t.name
714                        }
715                    })
716                    .unwrap_or_else(|| "-".to_string()),
717            );
718            ui.end_row();
719            ui.label("方向");
720            egui::ComboBox::from_id_salt("of_direction")
721                .selected_text(f.direction.as_str())
722                .show_ui(ui, |ui| {
723                    for d in [Direction::LONG, Direction::SHORT] {
724                        ui.selectable_value(&mut f.direction, d, d.as_str());
725                    }
726                });
727            ui.end_row();
728            ui.label("开平");
729            ui.horizontal(|ui| {
730                egui::ComboBox::from_id_salt("of_offset")
731                    .selected_text(f.offset.as_str())
732                    .show_ui(ui, |ui| {
733                        for o in [
734                            Offset::OPEN,
735                            Offset::CLOSE,
736                            Offset::CLOSETODAY,
737                            Offset::CLOSEYESTERDAY,
738                        ] {
739                            ui.selectable_value(&mut f.offset, o, o.as_str());
740                        }
741                    });
742                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
743                ui.checkbox(&mut f.auto_split, "自动开平");
744            });
745            ui.end_row();
746            ui.label("类型");
747            egui::ComboBox::from_id_salt("of_order_type")
748                .selected_text(f.order_type.as_str())
749                .show_ui(ui, |ui| {
750                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
751                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
752                    }
753                });
754            ui.end_row();
755            ui.label("价格");
756            ui.horizontal(|ui| {
757                ui.add(
758                    egui::TextEdit::singleline(&mut f.price)
759                        .hint_text("107660")
760                        .desired_width(90.0),
761                );
762                ui.checkbox(&mut f.follow_price, "随价");
763            });
764            ui.end_row();
765            ui.label("数量");
766            ui.add(
767                egui::TextEdit::singleline(&mut f.volume)
768                    .hint_text("1")
769                    .desired_width(90.0),
770            );
771            ui.end_row();
772            ui.label("接口");
773            egui::ComboBox::from_id_salt("of_gateway")
774                .selected_text(if f.gateway.is_empty() {
775                    "sim".to_string()
776                } else {
777                    f.gateway.clone()
778                })
779                .show_ui(ui, |ui| {
780                    for g in &gw_opts {
781                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
782                    }
783                });
784            ui.end_row();
785            ui.label("操作");
786            ui.horizontal(|ui| {
787                if ui.button(BTN_SEND_ORDER).clicked() {
788                    match f.to_request() {
789                        Ok(req) => match crate::state::parse_gateway(&f.gateway) {
790                            Ok(gateway) => {
791                                shared.push_cmd(UiCmd::SendOrder {
792                                    req,
793                                    gateway,
794                                    mode: if f.auto_split {
795                                        ek_oms::LockMode::Auto
796                                    } else {
797                                        ek_oms::LockMode::None
798                                    },
799                                });
800                                shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
801                            }
802                            Err(e) => {
803                                shared.log_line(format!("[下单] {e}"));
804                                err = Some(e);
805                            }
806                        },
807                        Err(e) => {
808                            shared.log_line(format!("[下单] {e}"));
809                            err = Some(e);
810                        }
811                    }
812                }
813                if ui.button(BTN_CANCEL_ALL).clicked() {
814                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
815                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
816                    let mut n = 0usize;
817                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
818                        let gw = if o.gateway_name.is_empty() {
819                            "sim".to_string()
820                        } else {
821                            o.gateway_name.clone()
822                        };
823                        shared.push_cmd(UiCmd::CancelOrder {
824                            orderid: o.orderid.clone(),
825                            gateway: gw,
826                        });
827                        n += 1;
828                    }
829                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
830                }
831            });
832            ui.end_row();
833            ui.label("代码回车 = 订阅");
834            ui.label("");
835            ui.end_row();
836        });
837    if let Some(e) = err {
838        ui.label(egui::RichText::new(e).color(egui::Color32::RED));
839    }
840    if f != before {
841        shared.set_order_form(f.clone());
842    }
843}
844
845/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
846/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
847/// 行情带跳色行指认,盘口帧不重复报幕)。
848fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
849    let f = shared.order_form();
850    match tick_of(shared, &f.exchange, &f.symbol) {
851        Some(t) => {
852            for i in (1..=5).rev() {
853                let (p, v) = match i {
854                    1 => (t.ask_price_1, t.ask_volume_1),
855                    2 => (t.ask_price_2, t.ask_volume_2),
856                    3 => (t.ask_price_3, t.ask_volume_3),
857                    4 => (t.ask_price_4, t.ask_volume_4),
858                    _ => (t.ask_price_5, t.ask_volume_5),
859                };
860                ui.label(
861                    egui::RichText::new(format!("卖{i}  {:.1}  {:.0}", p, v))
862                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
863                );
864            }
865            for i in 1..=5 {
866                let (p, v) = match i {
867                    1 => (t.bid_price_1, t.bid_volume_1),
868                    2 => (t.bid_price_2, t.bid_volume_2),
869                    3 => (t.bid_price_3, t.bid_volume_3),
870                    4 => (t.bid_price_4, t.bid_volume_4),
871                    _ => (t.bid_price_5, t.bid_volume_5),
872                };
873                ui.label(
874                    egui::RichText::new(format!("买{i}  {:.1}  {:.0}", p, v))
875                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
876                );
877            }
878        }
879        None => {
880            ui.label("(无 tick)");
881        }
882    }
883}
884
885// ================= 2×2 监控格 =================
886
887fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
888    let tab = shared.topleft_tab();
889    ui.horizontal(|ui| {
890        if ui
891            .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
892            .clicked()
893        {
894            shared.set_topleft_tab(TopLeftTab::Orders);
895        }
896        if ui
897            .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
898            .clicked()
899        {
900            shared.set_topleft_tab(TopLeftTab::Active);
901        }
902    });
903    render_orders_table(ui, shared, tab == TopLeftTab::Active);
904}
905
906/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
907/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
908/// 活动行撤单钮 + 双击撤单,终态行只读)。
909fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
910    let ts = shared.trade_snap();
911    // 表题撤除(第五轮审计):tab「委托|未成交」即 Python 口径的表身份,
912    // 再挂同名 label = 三重撞名(下单钮/tab/表题),纯冗余。
913    let rows: Vec<_> = ts
914        .orders
915        .iter()
916        .filter(|o| !active_only || order_active(o.status))
917        .collect();
918    if rows.is_empty() {
919        // 空态也走 pinned_table(0.2.6,用户报「未成交空表缩得很小」):表头 +
920        // 吃满滚动区照常在场,只缺数据行;旧版提前 return 让整格缩成一行字。
921        ui.label(if active_only {
922            "(无未成交)"
923        } else {
924            "(无委托:等网关 send_order)"
925        });
926    }
927    pinned_table(
928        ui,
929        if active_only {
930            "active_grid"
931        } else {
932            "order_grid"
933        },
934        17,
935        &[
936            "代码",
937            "名称",
938            "交易所",
939            "类型",
940            "方向",
941            "开平",
942            "价格",
943            "总量",
944            "已成",
945            "成交均价",
946            "状态",
947            "状态描述",
948            "时间",
949            "委托号",
950            "来源",
951            "接口",
952            "操作",
953        ],
954        |ui| {
955            for o in &rows {
956                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
957                // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
958                // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
959                let key = format!("{}.{}", o.gateway_name, o.orderid);
960                let row_sel = shared.row_selected("orders", &key);
961                let mut cells = Vec::with_capacity(17);
962                let mut dbl = false;
963                let sel = ui.selectable_label(row_sel, o.symbol.clone());
964                if sel.clicked() {
965                    shared.select_row("orders", &key);
966                }
967                dbl |= sel.double_clicked();
968                cells.push(sel.rect);
969                macro_rules! cell {
970                    ($base:expr, $col:expr) => {{
971                        let base = $base;
972                        let r = click_cell(ui, base.rect, ("ord", &key, $col));
973                        if r.clicked() {
974                            shared.select_row("orders", &key);
975                        }
976                        dbl |= r.double_clicked();
977                        cells.push(base.rect);
978                    }};
979                }
980                cell!(
981                    ui.label(if o.name.is_empty() {
982                        "-".to_string()
983                    } else {
984                        o.name.clone()
985                    }),
986                    1
987                );
988                cell!(ui.label(o.exchange.as_str().to_string()), 2);
989                cell!(ui.label(o.order_type.as_str().to_string()), 3);
990                cell!(
991                    ui.label(
992                        egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
993                            if o.direction == Some(Direction::SHORT) {
994                                egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
995                            } else {
996                                egui::Color32::from_rgb(0xE0, 0x50, 0x50)
997                            },
998                        ),
999                    ),
1000                    4
1001                );
1002                cell!(ui.label(o.offset.as_str().to_string()), 5);
1003                cell!(ui.label(format!("{:.1}", o.price)), 6);
1004                cell!(ui.label(format!("{:.0}", o.volume)), 7);
1005                cell!(ui.label(format!("{:.0}", o.traded)), 8);
1006                cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1007                cell!(
1008                    ui.label(egui::RichText::new(o.status.as_str()).color(
1009                        if o.status == ek_data::constant::Status::REJECTED {
1010                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1011                        } else if o.status == ek_data::constant::Status::ALLTRADED {
1012                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1013                        } else {
1014                            egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1015                        },
1016                    )),
1017                    10
1018                );
1019                cell!(ui.label(o.status_msg.clone()), 11);
1020                cell!(ui.label(fmt_dt(o.datetime)), 12);
1021                cell!(ui.label(o.orderid.clone()), 13);
1022                cell!(
1023                    ui.label(if o.reference.is_empty() {
1024                        "-".to_string()
1025                    } else {
1026                        o.reference.clone()
1027                    }),
1028                    14
1029                );
1030                cell!(ui.label(o.gateway_name.clone()), 15);
1031                if dbl && order_active(o.status) {
1032                    let gw = if o.gateway_name.is_empty() {
1033                        "sim".to_string()
1034                    } else {
1035                        o.gateway_name.clone()
1036                    };
1037                    shared.push_cmd(UiCmd::CancelOrder {
1038                        orderid: o.orderid.clone(),
1039                        gateway: gw,
1040                    });
1041                }
1042                let hr = ui.horizontal(|ui| {
1043                    if order_active(o.status)
1044                        && ui
1045                            .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1046                            .clicked()
1047                    {
1048                        // 撤单跟随委托的来路网关(行级 gateway_name;空串兜底 sim)
1049                        let gw = if o.gateway_name.is_empty() {
1050                            "sim".to_string()
1051                        } else {
1052                            o.gateway_name.clone()
1053                        };
1054                        shared.push_cmd(UiCmd::CancelOrder {
1055                            orderid: o.orderid.clone(),
1056                            gateway: gw,
1057                        });
1058                    }
1059                });
1060                cells.push(hr.response.rect);
1061                paint_row_bg(ui, &cells, row_sel);
1062                ui.end_row();
1063            }
1064        },
1065    );
1066}
1067
1068/// 成交组(12 列逐值 = Python TradeMonitor 蓝图)。
1069fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1070    ui.label("成交");
1071    let ts = shared.trade_snap();
1072    if ts.trades.is_empty() {
1073        ui.label("(无成交)"); // 空态也保表头(0.2.6,同未成交口径)
1074    }
1075    pinned_table(
1076        ui,
1077        "trade_grid",
1078        12,
1079        &[
1080            "成交号",
1081            "代码",
1082            "名称",
1083            "交易所",
1084            "方向",
1085            "开平",
1086            "价格",
1087            "数量",
1088            "成交额",
1089            "时间",
1090            "委托号",
1091            "接口",
1092        ],
1093        |ui| {
1094            for t in &ts.trades {
1095                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1096                // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
1097                let key = format!("{}.{}", t.gateway_name, t.tradeid);
1098                let row_sel = shared.row_selected("trades", &key);
1099                let mut cells = Vec::with_capacity(12);
1100                let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1101                if sel.clicked() {
1102                    shared.select_row("trades", &key);
1103                }
1104                cells.push(sel.rect);
1105                macro_rules! cell {
1106                    ($base:expr, $col:expr) => {{
1107                        let base = $base;
1108                        let r = click_cell(ui, base.rect, ("trd", &key, $col));
1109                        if r.clicked() {
1110                            shared.select_row("trades", &key);
1111                        }
1112                        cells.push(base.rect);
1113                    }};
1114                }
1115                cell!(ui.label(t.symbol.clone()), 1);
1116                cell!(
1117                    ui.label(if t.name.is_empty() {
1118                        "-".to_string()
1119                    } else {
1120                        t.name.clone()
1121                    }),
1122                    2
1123                );
1124                cell!(ui.label(t.exchange.as_str().to_string()), 3);
1125                cell!(
1126                    ui.label(
1127                        egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1128                            if t.direction == Some(Direction::SHORT) {
1129                                egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1130                            } else {
1131                                egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1132                            },
1133                        ),
1134                    ),
1135                    4
1136                );
1137                cell!(ui.label(t.offset.as_str().to_string()), 5);
1138                cell!(ui.label(format!("{:.1}", t.price)), 6);
1139                cell!(ui.label(format!("{:.0}", t.volume)), 7);
1140                cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1141                cell!(ui.label(fmt_dt(t.datetime)), 9);
1142                cell!(ui.label(t.orderid.clone()), 10);
1143                cell!(ui.label(t.gateway_name.clone()), 11);
1144                paint_row_bg(ui, &cells, row_sel);
1145                ui.end_row();
1146            }
1147        },
1148    );
1149}
1150
1151/// 持仓组(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
1152/// Python mainwindow.py:276 等价,不开 K线)。
1153fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1154    ui.label("持仓");
1155    let ts = shared.trade_snap();
1156    if ts.positions.is_empty() {
1157        ui.label("(无持仓)"); // 空态也保表头(0.2.6,同未成交口径)
1158    }
1159    pinned_table(
1160        ui,
1161        "pos_grid",
1162        16,
1163        &[
1164            "代码",
1165            "名称",
1166            "交易所",
1167            "方向",
1168            "数量",
1169            "昨仓",
1170            "可用",
1171            "冻结",
1172            "在途",
1173            "市值",
1174            "成本",
1175            "均价",
1176            "最新价",
1177            "盈亏",
1178            "盈亏比",
1179            "接口",
1180        ],
1181        |ui| {
1182            for p in &ts.positions {
1183                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1184                // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
1185                // 不开 K线;方向随持仓方向,Python 同口径)。
1186                let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1187                let row_sel = shared.row_selected("positions", &key);
1188                let mut cells = Vec::with_capacity(16);
1189                let mut dbl = false;
1190                let sel = ui.selectable_label(row_sel, p.symbol.clone());
1191                if sel.clicked() {
1192                    shared.select_row("positions", &key);
1193                }
1194                dbl |= sel.double_clicked();
1195                cells.push(sel.rect);
1196                macro_rules! cell {
1197                    ($base:expr, $col:expr) => {{
1198                        let base = $base;
1199                        let r = click_cell(ui, base.rect, ("pos", &key, $col));
1200                        if r.clicked() {
1201                            shared.select_row("positions", &key);
1202                        }
1203                        dbl |= r.double_clicked();
1204                        cells.push(base.rect);
1205                    }};
1206                }
1207                cell!(
1208                    ui.label(if p.name.is_empty() {
1209                        "-".to_string()
1210                    } else {
1211                        p.name.clone()
1212                    }),
1213                    1
1214                );
1215                cell!(ui.label(p.exchange.as_str().to_string()), 2);
1216                cell!(
1217                    ui.label(egui::RichText::new(p.direction.as_str()).color(
1218                        if p.direction == ek_data::constant::Direction::SHORT {
1219                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1220                        } else {
1221                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1222                        },
1223                    )),
1224                    3
1225                );
1226                cell!(ui.label(format!("{:.0}", p.volume)), 4);
1227                cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1228                cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1229                cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1230                cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1231                cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1232                cell!(ui.label(format!("{:.2}", p.price)), 10);
1233                cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1234                cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1235                cell!(
1236                    ui.label(
1237                        egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1238                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1239                        } else {
1240                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1241                        }),
1242                    ),
1243                    13
1244                );
1245                cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1246                cell!(ui.label(p.gateway_name.clone()), 15);
1247                if dbl {
1248                    let mut f = shared.order_form();
1249                    f.symbol = p.symbol.clone();
1250                    f.exchange = p.exchange.as_str().to_string();
1251                    f.direction = p.direction;
1252                    shared.set_order_form(f);
1253                    shared.log_line(format!("[ops] 持仓带入下单面板 {}", p.symbol));
1254                }
1255                paint_row_bg(ui, &cells, row_sel);
1256                ui.end_row();
1257            }
1258        },
1259    );
1260}
1261
1262fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1263    let tab = shared.bottomright_tab();
1264    ui.horizontal(|ui| {
1265        if ui
1266            .selectable_label(
1267                tab == BottomRightTab::Account,
1268                BottomRightTab::Account.label(),
1269            )
1270            .clicked()
1271        {
1272            shared.set_bottomright_tab(BottomRightTab::Account);
1273        }
1274        if ui
1275            .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1276            .clicked()
1277        {
1278            shared.set_bottomright_tab(BottomRightTab::Logs);
1279        }
1280    });
1281    match tab {
1282        BottomRightTab::Account => {
1283            let ts = shared.trade_snap();
1284            if ts.accounts.is_empty() {
1285                ui.label("(无资金:等网关 eAccount.)");
1286                return;
1287            }
1288            egui::Grid::new("acct_grid")
1289                .striped(true)
1290                .num_columns(5)
1291                .show(ui, |ui| {
1292                    for h in ["账号", "接口", "余额", "可用", "冻结"] {
1293                        ui.strong(h);
1294                    }
1295                    ui.end_row();
1296                    for a in &ts.accounts {
1297                        ui.label(a.accountid.clone());
1298                        ui.label(a.gateway_name.clone());
1299                        ui.label(format!("{:.2}", a.balance));
1300                        ui.label(format!("{:.2}", a.available()));
1301                        ui.label(format!("{:.2}", a.frozen));
1302                        ui.end_row();
1303                    }
1304                });
1305        }
1306        BottomRightTab::Logs => {
1307            render_logs(ui, shared);
1308        }
1309    }
1310}
1311
1312fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1313    ui.horizontal(|ui| {
1314        if ui.button(BTN_CLEAR_LOGS).clicked() {
1315            shared.clear_logs(); // LOCAL-FIRST:就地清,零往返
1316        }
1317        let (_, spilled) = shared.logs_snapshot();
1318        if spilled > 0 {
1319            ui.label(format!(
1320                "已丢弃 {spilled} 条(环形 {})",
1321                crate::state::RING_CAP
1322            ));
1323        }
1324    });
1325    let lines = shared.logs_snapshot().0;
1326    egui::ScrollArea::vertical()
1327        .id_salt("log_scroll")
1328        .max_height(180.0)
1329        .stick_to_bottom(true)
1330        .show(ui, |ui| {
1331            for line in lines {
1332                ui.monospace(line);
1333            }
1334        });
1335}
1336
1337// ================= 独立窗面(BD-UI-32/33)=================
1338
1339/// 独立窗几何(BD-UI-34:助手参数收敛,8→6 闸 clippy too_many_arguments)。
1340struct WinGeom {
1341    inner: [f32; 2],
1342    min_inner: [f32; 2],
1343    maximized: bool,
1344}
1345
1346/// 独立 OS 窗统一开法(BD-UI-33,四窗一型:运维/CTP/查询合约/K线)。
1347///
1348/// - **守卫(0.2.3 教训内建):** 调用方按状态位辞行——位关不调本函数 =
1349///   viewport 本拍未 used = egui/eframe GC 销毁 OS 窗(销毁判据源码
1350///   context.rs「never used this pass」)。
1351/// - Embedded 回退(后端不支持多窗:kittest/WEB)= 主窗内 egui::Window 同本体,
1352///   回退面关闭 = embedded 闭包返回 true。
1353/// - Immediate 面 OS X = close_requested → 返回 true(调用方翻负状态位)。
1354/// - `geom.maximized` = 打开即最大化(BD-UI-34,K线窗用户裁定;OS 窗属性,
1355///   Embedded 回退面无此概念 = 忽略)。
1356/// - `body` 返回 true = 内容侧请求关闭(BD-UI-34:K线窗 ESC 键快捷关闭;
1357///   键事件按子窗焦点路由,主窗按键不会误关)。
1358///
1359/// 返回 = "本拍该窗被关"(调用方负责状态位/列表出表)。
1360fn show_independent_window(
1361    ctx: &egui::Context,
1362    id_salt: impl std::hash::Hash,
1363    title: &str,
1364    geom: &WinGeom,
1365    embedded: impl Fn(&egui::Context) -> bool,
1366    body: impl Fn(&mut egui::Ui) -> bool,
1367) -> bool {
1368    let closed = std::cell::Cell::new(false);
1369    let mut builder = egui::ViewportBuilder::default()
1370        .with_title(title)
1371        .with_inner_size(geom.inner)
1372        .with_min_inner_size(geom.min_inner)
1373        .with_minimize_button(true)
1374        .with_maximize_button(true)
1375        .with_close_button(true);
1376    if geom.maximized {
1377        builder = builder.with_maximized(true);
1378    }
1379    ctx.show_viewport_immediate(
1380        egui::ViewportId::from_hash_of(id_salt),
1381        builder,
1382        |ctx2, class| {
1383            if class == egui::ViewportClass::Embedded {
1384                closed.set(embedded(ctx2));
1385                return;
1386            }
1387            if ctx2.input(|i| i.viewport().close_requested()) {
1388                closed.set(true);
1389                return;
1390            }
1391            egui::CentralPanel::default().show(ctx2, |ui| {
1392                if body(ui) {
1393                    closed.set(true);
1394                }
1395            });
1396        },
1397    );
1398    closed.get()
1399}
1400
1401fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1402    // 运维窗(菜单「运维」;BD-UI-33 转独立 OS 窗,用户裁定「四窗皆可脱离主窗」)。
1403    if shared.ops_window_open() {
1404        let closed = show_independent_window(
1405            ctx,
1406            "ops_win",
1407            "运维窗",
1408            &WinGeom {
1409                inner: [680.0, 420.0],
1410                min_inner: [420.0, 260.0],
1411                maximized: false,
1412            },
1413            |ctx2| {
1414                let mut open = shared.ops_window_open();
1415                egui::Window::new("运维窗")
1416                    .id(egui::Id::new("ops_win"))
1417                    .default_pos([80.0, 320.0])
1418                    .default_size([640.0, 380.0])
1419                    .open(&mut open)
1420                    .show(ctx2, |ui| {
1421                        render_ops_window(ui, shared);
1422                    });
1423                shared.set_ops_window_open(open);
1424                !open
1425            },
1426            |ui| {
1427                render_ops_window(ui, shared);
1428                false
1429            },
1430        );
1431        if closed {
1432            shared.set_ops_window_open(false);
1433        }
1434    }
1435
1436    // CTP 配置窗(菜单「网关」;CTP 插件自己的界面;BD-UI-33 转独立 OS 窗)。
1437    if shared.ctp_window_open() {
1438        let closed = show_independent_window(
1439            ctx,
1440            "ctp_win",
1441            "CTP 网关配置窗",
1442            &WinGeom {
1443                inner: [600.0, 380.0],
1444                min_inner: [420.0, 260.0],
1445                maximized: false,
1446            },
1447            |ctx2| {
1448                let mut open = shared.ctp_window_open();
1449                egui::Window::new("CTP 网关配置窗")
1450                    .id(egui::Id::new("ctp_win"))
1451                    .default_pos([640.0, 320.0])
1452                    .default_size([560.0, 330.0])
1453                    .open(&mut open)
1454                    .show(ctx2, |ui| {
1455                        render_ctp_window(ui, shared);
1456                    });
1457                shared.set_ctp_window_open(open);
1458                !open
1459            },
1460            |ui| {
1461                render_ctp_window(ui, shared);
1462                false
1463            },
1464        );
1465        if closed {
1466            shared.set_ctp_window_open(false);
1467        }
1468    }
1469
1470    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)。
1471    if shared.contracts_window_open() {
1472        let closed = show_independent_window(
1473            ctx,
1474            "contracts_win",
1475            "查询合约窗",
1476            &WinGeom {
1477                inner: [620.0, 460.0],
1478                min_inner: [420.0, 260.0],
1479                maximized: false,
1480            },
1481            |ctx2| {
1482                let mut open = shared.contracts_window_open();
1483                egui::Window::new("查询合约窗")
1484                    .id(egui::Id::new("contracts_win"))
1485                    .default_pos([420.0, 300.0])
1486                    .default_size([560.0, 420.0])
1487                    .open(&mut open)
1488                    .show(ctx2, |ui| {
1489                        render_contracts_window(ui, shared);
1490                    });
1491                shared.set_contracts_window_open(open);
1492                !open
1493            },
1494            |ui| {
1495                render_contracts_window(ui, shared);
1496                false
1497            },
1498        );
1499        if closed {
1500            shared.set_contracts_window_open(false);
1501        }
1502    }
1503
1504    // K线窗(双击弹出,按合约多开;出 kline_symbols 列表 = 不再 show = 销毁)。
1505    // BD-UI-34:打开即最大化(maximized)+ ESC 键快捷关闭(body 请求关闭;
1506    // 键按子窗焦点路由,主窗按键不误关)。
1507    let mut closed: Vec<String> = Vec::new();
1508    for sym in shared.kline_symbols() {
1509        let is_closed = show_independent_window(
1510            ctx,
1511            format!("kline_win:{sym}"),
1512            &format!("K线 — {sym}"),
1513            &WinGeom {
1514                inner: [560.0, 360.0],
1515                min_inner: [360.0, 220.0],
1516                maximized: true,
1517            },
1518            |ctx2| {
1519                let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
1520                let resp = egui::Window::new(format!("K线 — {sym}"))
1521                    .id(egui::Id::new(("kline_win", sym.clone())))
1522                    .default_pos([300.0, 340.0])
1523                    .default_size([540.0, 330.0])
1524                    .show(ctx2, |ui| {
1525                        let cell = shared
1526                            .market()
1527                            .cells()
1528                            .into_iter()
1529                            .find(|c| c.symbol == sym);
1530                        render_kline_cell(ui, cell.as_ref());
1531                    });
1532                esc || resp.is_none()
1533            },
1534            |ui| {
1535                if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
1536                    return true;
1537                }
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                false
1545            },
1546        );
1547        if is_closed {
1548            closed.push(sym);
1549        }
1550    }
1551    for s in closed {
1552        shared.close_kline(&s);
1553    }
1554}
1555
1556/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
1557/// + 依赖图 + 流控查询钮。回灌界面面已退役(总线口保留)。
1558fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1559    ui.label("插件");
1560    let snap = shared.snapshot();
1561    egui::Grid::new("plug_grid")
1562        .striped(true)
1563        .num_columns(6)
1564        .show(ui, |ui| {
1565            ui.strong("名称");
1566            ui.strong("状态");
1567            ui.strong("版本");
1568            ui.strong("依赖");
1569            ui.strong("统计");
1570            ui.strong("操作");
1571            ui.end_row();
1572            if snap.rows.is_empty() {
1573                ui.label("(快照空:等 eTimer 首拉)");
1574                ui.end_row();
1575            }
1576            for r in &snap.rows {
1577                if is_quarantined(&r.state) {
1578                    ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1579                    ui.label(
1580                        egui::RichText::new(state_text(r))
1581                            .color(egui::Color32::RED)
1582                            .strong(),
1583                    );
1584                } else {
1585                    ui.label(r.name.clone());
1586                    ui.label(state_text(r));
1587                }
1588                ui.label(r.version.clone());
1589                ui.label(format!("deps:[{}]", r.deps.join(",")));
1590                ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1591                ui.horizontal(|ui| {
1592                    if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1593                        shared.push_cmd(UiCmd::Start {
1594                            factory: r.name.clone(),
1595                        });
1596                    }
1597                    let armed = shared.pending_confirm();
1598                    let armed_here = ui
1599                        .small_button(remove_button_label(&r.name, armed.as_deref()))
1600                        .clicked()
1601                        && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1602                    if armed_here {
1603                        shared.push_cmd(UiCmd::Remove {
1604                            name: r.name.clone(),
1605                        });
1606                    }
1607                });
1608                ui.end_row();
1609            }
1610        });
1611
1612    if let Some(name) = shared.pending_confirm() {
1613        ui.horizontal(|ui| {
1614            ui.label(
1615                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1616                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1617            );
1618            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1619                shared.confirm_cancel();
1620            }
1621        });
1622    }
1623
1624    ui.label("服务");
1625    let flat: Vec<String> = snap
1626        .services
1627        .iter()
1628        .flat_map(|(n, ids)| {
1629            if ids.is_empty() {
1630                vec![format!("{n} 服务"), "-".to_string()]
1631            } else {
1632                ids.iter()
1633                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
1634                    .collect()
1635            }
1636        })
1637        .collect();
1638    if flat.is_empty() {
1639        ui.label("(无服务目录)");
1640    } else {
1641        let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
1642        egui::Grid::new("svc_grid")
1643            .striped(true)
1644            .num_columns(6)
1645            .show(ui, |ui| {
1646                for r in 0..rows {
1647                    for c in 0..3 {
1648                        let i = c * rows * 2 + r * 2;
1649                        if i < flat.len() {
1650                            ui.label(flat[i].clone());
1651                            ui.label(flat[i + 1].clone());
1652                        } else {
1653                            ui.label("");
1654                            ui.label("");
1655                        }
1656                    }
1657                    ui.end_row();
1658                }
1659            });
1660    }
1661
1662    ui.label("依赖");
1663    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1664        ui.label("(全部无依赖)");
1665    }
1666    for r in &snap.rows {
1667        if !r.deps.is_empty() {
1668            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1669        }
1670    }
1671
1672    ui.horizontal(|ui| {
1673        if ui.small_button(BTN_QUERY_RATE).clicked() {
1674            shared.push_cmd(UiCmd::QueryRate);
1675        }
1676    });
1677}
1678
1679/// CTP 配置窗渲染(S6 定盘口径:账号/前置全部界面手工输入,插件不读文件;
1680/// `连接 CTP` → ask ctp.configure;密码位掩码。**无订阅字段**(用户裁定:
1681/// 订阅不在网关窗定死,走下单面板代码回车或查询合约窗勾选))。
1682fn render_ctp_window(ui: &mut egui::Ui, shared: &UiShared) {
1683    ui.label("账号与前置手工输入(插件自带配置面;密码不回显)");
1684    let mut form: crate::state::CtpForm = shared.ctp_form();
1685    egui::Grid::new("ctp_grid").num_columns(6).show(ui, |ui| {
1686        ui.label("broker");
1687        ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
1688        ui.label("td 前置");
1689        ctp_edit(
1690            ui,
1691            shared,
1692            &mut form.td_addr,
1693            "tcp://host:40001",
1694            CtpField::TdAddr,
1695        );
1696        ui.label("td 用户");
1697        ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
1698        ui.end_row();
1699        ui.label("td 密码");
1700        ctp_edit(
1701            ui,
1702            shared,
1703            &mut form.td_password,
1704            "密码",
1705            CtpField::TdPassword,
1706        );
1707        ui.label("AppID");
1708        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
1709        ui.label("AuthCode");
1710        ctp_edit(
1711            ui,
1712            shared,
1713            &mut form.td_auth_code,
1714            "授权码",
1715            CtpField::AuthCode,
1716        );
1717        ui.end_row();
1718        ui.label("md 前置");
1719        ctp_edit(
1720            ui,
1721            shared,
1722            &mut form.md_addr,
1723            "tcp://host:40011",
1724            CtpField::MdAddr,
1725        );
1726        ui.label("md 用户");
1727        ctp_edit(
1728            ui,
1729            shared,
1730            &mut form.md_user,
1731            "空=td 回落",
1732            CtpField::MdUser,
1733        );
1734        ui.label("md 密码");
1735        ctp_edit(
1736            ui,
1737            shared,
1738            &mut form.md_password,
1739            "空=td 回落",
1740            CtpField::MdPassword,
1741        );
1742        ui.end_row();
1743    });
1744    ui.horizontal(|ui| {
1745        if ui.small_button(BTN_CTP_CONNECT).clicked() {
1746            shared.push_cmd(UiCmd::CtpConfigure(form.clone()));
1747            shared.log_line("[ops] CTP 配置已入列(等 eTimer 请辞 → ctp.configure)");
1748        }
1749        ui.label("订阅不在本网关窗定死:查询合约窗勾选订阅,或下单面板代码回车订阅(用户裁定)");
1750    });
1751}
1752
1753/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
1754/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
1755/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
1756fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
1757    let mut q = shared.contract_query();
1758    ui.horizontal(|ui| {
1759        ui.label("代码或交易所(留空 = 全部):");
1760        if ui
1761            .add(
1762                egui::TextEdit::singleline(&mut q)
1763                    .hint_text("cu 或 SHFE")
1764                    .desired_width(200.0),
1765            )
1766            .changed()
1767        {
1768            shared.set_contract_query(q.clone());
1769        }
1770    });
1771    let rows = shared.contracts(&q);
1772    if rows.is_empty() {
1773        // 空态也保表头(0.2.6,同未成交口径);动作行无意义可省,表格照常吃满。
1774        ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
1775        pinned_table(
1776            ui,
1777            "contract_grid",
1778            5,
1779            &["代码", "名称", "交易所", "乘数", "最后价"],
1780            |_ui| {},
1781        );
1782        return;
1783    }
1784    let selected = shared.selected_contracts();
1785    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
1786    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
1787    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
1788    // 行溢出才出滚动条。
1789    ui.horizontal(|ui| {
1790        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
1791        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
1792            if selected.is_empty() {
1793                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
1794            } else {
1795                // en_symbol 勾选集 → CTP 裸代码订阅(ask ctp.subscribe 收裸代码)
1796                let symbols: Vec<String> = selected
1797                    .iter()
1798                    .map(|k| {
1799                        k.split_once('.')
1800                            .map(|(_, s)| s.to_string())
1801                            .unwrap_or_else(|| k.clone())
1802                    })
1803                    .collect();
1804                shared.push_cmd(UiCmd::CtpSubscribe { symbols });
1805                shared.log_line(format!(
1806                    "[ops] 查询合约窗订阅 {} 项已入列(等 eTimer 请辞 → ctp.subscribe)",
1807                    selected.len()
1808                ));
1809            }
1810        }
1811    });
1812    pinned_table(
1813        ui,
1814        "contract_grid",
1815        5,
1816        &["选(代码)", "交易所", "名称", "乘数", "最小变动"],
1817        |ui| {
1818            for c in &rows {
1819                let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
1820                let mut on = selected.contains(&key);
1821                // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
1822                // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
1823                // 行选中(动作归行,不归某一列)。
1824                let row_sel = shared.row_selected("contracts", &key);
1825                let mut cells = Vec::with_capacity(5);
1826                let ck = ui.checkbox(&mut on, key.clone());
1827                if ck.changed() {
1828                    shared.toggle_contract_selection(&key);
1829                    shared.select_row("contracts", &key);
1830                }
1831                cells.push(ck.rect);
1832                macro_rules! cell {
1833                    ($base:expr, $col:expr) => {{
1834                        let base = $base;
1835                        let r = click_cell(ui, base.rect, ("crt", &key, $col));
1836                        if r.clicked() {
1837                            shared.select_row("contracts", &key);
1838                        }
1839                        cells.push(base.rect);
1840                    }};
1841                }
1842                cell!(ui.label(c.exchange.as_str().to_string()), 1);
1843                cell!(ui.label(c.name.clone()), 2);
1844                cell!(ui.label(format!("{:.0}", c.size)), 3);
1845                cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
1846                paint_row_bg(ui, &cells, row_sel);
1847                ui.end_row();
1848            }
1849        },
1850    );
1851}
1852
1853/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
1854enum CtpField {
1855    Broker,
1856    TdAddr,
1857    TdUser,
1858    TdPassword,
1859    AppId,
1860    AuthCode,
1861    MdAddr,
1862    MdUser,
1863    MdPassword,
1864}
1865
1866/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
1867fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
1868    let is_secret = matches!(
1869        field,
1870        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
1871    );
1872    let mut w = egui::TextEdit::singleline(val)
1873        .hint_text(hint)
1874        .desired_width(110.0);
1875    if is_secret {
1876        w = w.password(true);
1877    }
1878    if ui.add(w).changed() {
1879        let mut f = shared.ctp_form();
1880        match field {
1881            CtpField::Broker => f.broker_id = val.clone(),
1882            CtpField::TdAddr => f.td_addr = val.clone(),
1883            CtpField::TdUser => f.td_user = val.clone(),
1884            CtpField::TdPassword => f.td_password = val.clone(),
1885            CtpField::AppId => f.td_app_id = val.clone(),
1886            CtpField::AuthCode => f.td_auth_code = val.clone(),
1887            CtpField::MdAddr => f.md_addr = val.clone(),
1888            CtpField::MdUser => f.md_user = val.clone(),
1889            CtpField::MdPassword => f.md_password = val.clone(),
1890        }
1891        shared.set_ctp_form(f);
1892    }
1893}
1894
1895/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
1896///
1897/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
1898fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
1899    let Some(c) = cell else {
1900        ui.label("(K线无格:等 dm 合成)");
1901        return;
1902    };
1903    if c.bars.is_empty() {
1904        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
1905        return;
1906    }
1907    let mut data = egui_charts::model::bar::BarData::new();
1908    for b in &c.bars {
1909        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime);
1910        data.push(egui_charts::model::bar::Bar::new(
1911            dt,
1912            b.open_price,
1913            b.high_price,
1914            b.low_price,
1915            b.close_price,
1916            b.volume,
1917        ));
1918    }
1919    ui.label(format!(
1920        "K线 {}@{}s({} 根)",
1921        c.symbol,
1922        c.interval,
1923        c.bars.len()
1924    ));
1925    let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
1926    chart.set_symbol(&c.symbol);
1927    chart.set_timeframe_label(&format!("{}s", c.interval));
1928    chart.enable_tracking_mode();
1929    let _ = chart.show(ui);
1930}