Skip to main content

ek_ui/
render.rs

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