Skip to main content

ek_ui/
render.rs

1//! 唯一渲染入口(L2):真实 eframe App 与 kittest 建构走同一个 `render_body`。
2//!
3//! 布局 v7(0.4.1 BD-UI-54:拆停靠库、回归原生容器;蓝本 = Python 主窗 mainwindow.py):
4//! **TopBottomPanel(菜单) → TopBottomPanel(行情带) → SidePanel(下单) →
5//! CentralPanel(数据区 2×2 = 薄 QSplitter 两刀) → Window 弹窗**。
6//! - 行情带 = 六所固定 tab 条(`state::MARKET_ORDER`,用户裁定「每个交易所都要一个」;
7//!   标签 = 交易所**中文名**,条在**表之下**且先给它预留高度不许外溢 —— 九期 BD-UI-62)
8//!   加当前所的行情表;每个所都有自己的表与空态。只有 tick 表,没有按所的滚动表
9//!   —— 唯一源 = `eTick.` 订阅流,不从 bar 回。行内任意格单击 = 带入下单面板
10//!   (ek 增值,方案钉:盘口四列带方向,买1 价/量 → 卖,卖1 价/量 → 买,价格列
11//!   顺带填价,其余列方向保持默认 —— 0.2.1 `import_tick`),双击 = 弹 K线窗。
12//! - 下单侧(`SidePanel::left`,缺省 300 ≈ Python `setFixedWidth(300)`):下单面板
13//!   (Python TradingWidget 等价:9 字段 + 委托 + 全撤;表单 = 网格,按钮/提示语都是
14//!   网格行;网格后置 err label 由 ScrollArea 吸收 sizing 退让,BD-UI-27)+
15//!   盘口(买5卖5,Python TradingWidget 内序:字段钮 → 盘口)。
16//! - 数据区组容器:委托|未成交 / 成交 / 持仓 / 资金|日志 四组(Python 四个 QTabWidget
17//!   等价,tab 条 = 自绘 `tab_label`),2×2 两刀 = `crate::splitters`(份额可拖,
18//!   态在 `shared.data_split`,恢复布局回 50:50);撤列后的撤单口径:行双击 + 全撤 +
19//!   先撤(0.8.5,s11 改向);委托行双击 = 撤单,Python OrderMonitor 等价;
20//!   格内宽表走横向滚动,D7 口径)。
21//! - 弹窗两式(BD-UI-32):运维窗(菜单「运维」:插件/服务/依赖;0.2.10 起
22//!   流控查询钮随 sim 退场撤除)、
23//!   CTP 配置窗(菜单「网关」,CTP 插件自己的界面)= egui::Window 依附主窗;
24//!   查询合约窗(菜单「查询合约」:查询 + 勾选 + 订阅钮,Python ContractManager
25//!   等价)、K线窗(双击弹出,按合约多开)= 独立 OS 窗(show_viewport_immediate,
26//!   原生最小化/最大化/关闭,可拖出主窗;后端不支持多窗自动回退 egui::Window,
27//!   kittest 走回退面断言)。
28//! - 订阅两入口(用户裁定,网关窗不定死订阅):下单面板代码框**回车**订阅
29//!   (Python returnPressed 等价);查询合约窗**勾选订阅**。合约数据 =
30//!   `eContract.` 自动回流(ek-ctp 登录后自动查询,UI 只收不催)。
31//! - 回灌表单与票据文本框已退役(界面面);总线口(`UiCmd::Reload` 等)保留。
32//!
33//! kittest 断言面:各单元格独立 Label(`get_by_label` 直查);操作走
34//! `push_cmd` 入队(ui 插件 eTimer 辞行出队),确认状态就地翻位,零自制通道。
35//! 隔离红标:行状态含 Quarantin(快照扫描,BD-UI-17)。
36
37use ek_data::constant::{Direction, Offset, OrderType};
38use ek_station::SVCTL_NAME;
39use ek_station::wire::PlugRow;
40
41use crate::plugin::UI_NAME;
42use crate::state::{
43    BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, beijing_tz, is_quarantined,
44};
45
46/// 取消待确认按钮标签。
47pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
48/// 下单提交按钮标签(v4 = Python「委托」钮)。
49pub const BTN_SEND_ORDER: &str = "委托";
50/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
51pub const BTN_CANCEL_ALL: &str = "全撤";
52/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
53pub const BTN_CANCEL_ORDER: &str = "撤单";
54/// 账户面板:添加账户钮(派生注册名 → svctl.start + {gw}.configure)。
55pub const BTN_ACCOUNT_ADD: &str = "添加账户";
56/// 菜单项:运维(弹运维窗)。
57pub const MENU_OPS: &str = "运维";
58/// 菜单项:账户(弹账户面板;多账号面入口,规划 §5.B。旧「网关」单账号窗
59/// 已退役收纳进账户面板的添加/编辑表单,旧常量随之删除)。
60pub const MENU_ACCOUNTS: &str = "账户";
61/// 菜单项:帮助。
62pub const MENU_HELP: &str = "帮助";
63/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
64pub const MENU_CONTRACTS: &str = "查询合约";
65/// 菜单项 → 弹窗入口钮(BD-UI-31 一键直达:菜单钮即入口,一步弹窗,
66/// 不再挂下拉层;入口钮标签 = 菜单名本身)。
67/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
68pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
69/// 运维窗:装配显形手动重问钮(0.5 M3 —— 管线读数改吃事件后唯一的手动口子)。
70pub const BTN_PIPELINE_REFRESH: &str = "重问管线";
71/// 运维窗「配置托底」节:段目录刷新钮(0.6.0 环节8)。
72pub const BTN_CFG_REFRESH: &str = "刷新段落目录";
73
74/// F4 口径句(0.2.20,D-UI-F4-SCOPE):配置弹窗**唯一在场位**(0.9.10 面板底部
75/// 说明行退役,用户裁定「表下不留杂说明」;改勾选的现场 = 弹窗,口径跟着现场走)。
76pub const TXT_PRODUCT_CLASS_SCOPE: &str = "收录勾选只管该网关自己收录并上抛的合约;公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)。改勾选须『连接/重试』才下发到网关。";
77
78/// 订阅路由(规划 §5.C):行情订阅一律发往**行情元账号**。
79///
80/// 未指定行情源时回 `Err` —— 不静默发给某个从账号(纯 TD 账号收到订阅会
81/// 恒 `ok:true` 但永远不来 tick,是观测黑洞)。
82fn subscribe_target(shared: &UiShared) -> Result<String, String> {
83    let md = shared.md_source();
84    if md.is_empty() {
85        return Err("未指定行情源:账户面板点某账户的『设为行情源』后再订阅".to_string());
86    }
87    Ok(md)
88}
89
90fn state_text(r: &PlugRow) -> String {
91    r.state.clone()
92}
93
94/// 运维窗「统计」列的颜色(0.4.5 P7d 首发;0.5 M6 加容量参数;0.7.0 背压刀改吃水位)。
95///
96/// 新口径(随 ek-core 0.5.0 无界邮箱):判据的**主角是积压**(`pending`),
97/// 阈值吃**这一行自己的软顶**(`soft_cap`,由 ek-station 0.4.0 随快照带来):
98/// - `panics > 0` / `spilled > 0`(已触顶丢新帧)/ 积压越红阈 → ERROR;
99/// - 积压越黄阈 → WARN;
100/// - 全 0 → MUTED(这一格的意思是「没事」,不是数据)。
101///
102/// 软顶必须传进来:界面自己 10 万、网关吃缺省 262144,同一根绝对线在一边是
103/// 「快炸了」在另一边是「刚起步」(阈值口径见 `state::pending_thresholds`)。
104/// 公开是为了让这一判据可钉 —— 颜色本身在 kittest 里只能靠形状颜色判,
105/// 把决策抽成纯函数才能直接断言。
106pub fn stat_color(
107    theme: crate::tokens::Theme,
108    spilled: u64,
109    panics: u64,
110    pending: u64,
111    soft_cap: u64,
112) -> egui::Color32 {
113    let (warn, crit) = crate::state::pending_thresholds(soft_cap);
114    let s = crate::tokens::semantic_of_theme(theme);
115    if panics > 0 || spilled > 0 || pending >= crit {
116        s.error
117    } else if pending >= warn {
118        s.warn
119    } else {
120        s.muted
121    }
122}
123
124// 0.3.1(Wave D,BD-UI-49):时间列格式收进 `crate::format::time_hms_millis`(`HH:MM:SS.mmm`)。
125// 口径没变:北京时间 +08,数据面时间戳统一存 UTC 纳秒 —— CTP 回报的自然日本就是北京钟面,
126// 入账按 +08 换算存 UTC,展示面换回来,表内时间列与交易员作息同钟面,且与日志同源。
127
128// 展示面统一时区 = `crate::state::beijing_tz`(0.2.27 提到 state:日志入环打戳
129// 与表格时间列必须同一个口径,一份实现两处共用)。这里不留 doc comment ——
130// 它后面没有 item,clippy `empty_lines_after_doc_comment` 正是钉这种。
131
132// ---- BD-UI-31(R3)行高亮:由表格层承担(0.2.29 BD-UI-44 定口径,0.4.1 BD-UI-55 换库)----
133// 原来这里有两支手绘助手:`paint_row_bg`(行底色 = 各格响应矩形并集 + 硬编码半透明蓝)
134// 和 `click_cell`(在标签矩形上叠一层点击感应)。两支助手在 BD-UI-44 撤除:底色改吃主题
135// (`visuals.selection.bg_fill` / `widgets.hovered.bg_fill`,整行连续),格可点 = 表格层
136// 自己给的 Response。BD-UI-55 迁 egui_table 之后这条口径没变,只是实现搬到 delegate 的
137// `row_ui`(选中 / 悬停底色,行带全宽)与 `pin_cell`(格点击感应)。行底色仍跟主题
138// (0.2.27 斑马纹同口径:唯一主题下 `visuals.striped = false`,不许硬开)。
139
140/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
141fn order_active(status: ek_data::constant::Status) -> bool {
142    ek_data::constant::ACTIVE_STATUSES.contains(&status)
143}
144
145/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
146/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
147/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
148pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
149    let mut f = shared.order_form();
150    f.symbol = t.symbol.clone();
151    f.exchange = exchange.to_string();
152    match col {
153        8 => {
154            f.direction = Direction::SHORT;
155            f.price = crate::format::price(t.bid_price_1);
156        }
157        9 => f.direction = Direction::SHORT,
158        10 => {
159            f.direction = Direction::LONG;
160            f.price = crate::format::price(t.ask_price_1);
161        }
162        11 => f.direction = Direction::LONG,
163        _ => {}
164    }
165    shared.set_order_form(f);
166}
167
168/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
169/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
170fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
171    if symbol.is_empty() {
172        return None;
173    }
174    shared.tick_rows().into_iter().find(|t| {
175        t.symbol == symbol
176            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
177    })
178}
179
180/// 字号 + 间距 + 控件规格 + 主题视觉(0.2.30 起全部走 `crate::tokens`;主题只有一套
181/// Qt深色,`Theme` 枚举与菜单循环钮已删 —— 裁定 8)。真实 App、独立子窗(immediate
182/// 共享同一 Context)、kittest 三处同源。
183/// 语义色(涨红跌绿 / 多红空绿 / 警告橙 / 错误深红 / 成功深绿 / 次级灰)**不在这一层**:
184/// 它们在 `tokens::semantic` 里恒定 —— 0.8.0 的口径是「**hue 恒定、明度跟档**」
185/// (判据 `u11` 钉着:浅档也不许把跌染成蓝),渲染侧走 `tokens::rise(ctx)` 等访问器取当前档。
186pub fn apply_ui_style(ctx: &egui::Context, theme: crate::tokens::Theme) {
187    ctx.style_mut(|style| {
188        crate::tokens::apply(style);
189    });
190    // 0.8.0 双档:档写进 Context::data(渲染侧取色访问器的唯一读源)+ 装这一档的 Visuals。
191    crate::tokens::set_theme(ctx, theme);
192    ctx.set_visuals(crate::tokens::visuals_for(theme));
193}
194
195/// 表格与页签的「chrome」配色(表头带、分隔线、tab 头底、选中 tab 下划线)。
196///
197/// 为什么要有这个结构:shadcn 有「表头带底」(`secondary` `#262626`)和「选中 tab 底 +
198/// 下划线」这两个概念,Python 侧对应 qdarkstyle 的 `QHeaderView::section`
199/// (表头带底 `#455364`)与 `QTabBar::tab:top:selected`(选中 tab `#54687A` 底 + 3px
200/// `#259AE9` 下划线),**egui 的 `Visuals` 里没有对应字段**(它只有 `selection.bg_fill`
201/// 一根,且 `selectable_label` 只能整块填充)。所以这一层只能在渲染侧直绘。
202///
203/// 取色口径(审计「多余」条):不新造颜色,全部从主题**已经声明过**的字段里取 ——
204/// 带底取 `widgets.inactive.bg_fill`,描边取 `noninteractive.bg_stroke`,表头文字取
205/// `noninteractive.fg_stroke`,选中 tab 底取 `widgets.hovered.bg_fill`,下划线取
206/// `hyperlink_color`。0.2.30 起主题只有一套,原来「按主题分叉」的那条 match 随之消失。
207///
208/// 0.4.1 四期(BD-UI-57)拆掉一根旧耦合:旧 qdark 里 `BG4` **同时**是表头带底和一切描边
209/// (`noninteractive.bg_stroke` = `#455364` = 带底),所以 `line` 一支两用。Nova 把这两件事
210/// 分成 `secondary` `#262626`(带底)与 `border`(白 10%,一切描边)→ 于是多出 `band_fill`
211/// 这一支,`line` 只管线不管底。
212///
213/// 0.4.0 期间 tab 那四支随「组内二级 tab 条撤除、tab 归 dock」删过一轮;0.4.1
214/// (BD-UI-54)tab 形态回归原生(自绘 `tab_label`),四支原样归位 —— 派生路径
215/// (同一份 `visuals`,`s67` 钉住)不变。
216#[derive(Clone, Copy)]
217struct Chrome {
218    /// 一切分隔线 / 网格线 / 行线 / 窗框(1px)。旧口径这一支兼做表头带底,现已拆开。
219    line: egui::Color32,
220    /// **行线专用**(0.8.2):参考站分 `--st-line`(表头那根)与 `--st-line-soft`(行线),
221    /// 0.8.0 通吃一支 → 行线比参考站重一档。表头带下边线与窗框仍然吃 `line`。
222    line_soft: egui::Color32,
223    /// 表头带底 + 未选中 tab 的头底。
224    band_fill: egui::Color32,
225    /// 表头带文字。
226    header_text: egui::Color32,
227    /// 选中 tab 的底。
228    tab_fill: egui::Color32,
229    /// 选中 tab 的文字。
230    tab_text: egui::Color32,
231    /// 选中 tab 的下划线(qss 3px 那支)。
232    tab_underline: egui::Color32,
233}
234
235impl Chrome {
236    /// 从当前 visuals + 当前档派生(0.8.0 双档:派生规则唯一,只是入参多了一个档)。
237    ///
238    /// 0.8.0 新增的口径:**表头文字走次级色 `text3`**(参考站 `th{color:var(--st-ink-muted)}`
239    /// 口径,也是裁定 C 引入三层文字后的一层落点)。旧口径表头文字 = `noninteractive.fg_stroke`
240    /// = 正文色,与表体同色 = 层级塌一档。
241    ///
242    /// 0.8.2 的教训写在这里:这一支在 0.8.0 就**存在且被判据钉过**(u10/`Chrome` 字段钉),
243    /// 但六张表的表头是 `header_cell_ui` 自己画的、那里**没吃这一支** → 钉绿而屏幕错。
244    /// 现在表头两处(Grid 短表的覆盖带 + pinned_table 的 `header_cell_ui`)都吃 `header_text`,
245    /// 判据 `s94` 钉的是**落屏颜色**,不是字段值。
246    fn of(v: &egui::Visuals, theme: crate::tokens::Theme) -> Self {
247        Self {
248            line: v.widgets.noninteractive.bg_stroke.color,
249            line_soft: theme.palette().border_soft,
250            band_fill: v.widgets.inactive.bg_fill,
251            header_text: theme.palette().text3,
252            tab_fill: v.widgets.hovered.bg_fill,
253            tab_text: v.widgets.hovered.fg_stroke.color,
254            tab_underline: v.hyperlink_color,
255        }
256    }
257}
258
259/// 页签(BD-UI-41,对照 qdarkstyle `QTabBar::tab:top`)。
260///
261/// Python 侧四个区全是 QTabWidget(`mainwindow.py:106-204`,连只有一个 tab 的
262/// 「成交」「持仓」也带 tab 头),qss 给 tab 的形态是:
263///   未选中 `QTabBar::tab:top { background #455364; padding 2px 4px }` +
264///     `!selected { border-bottom: 2px solid #19232D }`(qss:1579 / 1559)
265///   选中   `QTabBar::tab:top:selected { background #54687A;
266///     border-bottom: 3px solid #259AE9; 上圆角 4px }`(qss:1592)
267/// egui 的 `selectable_label` 只有「整块 selection 填充」,画不出下划线,也没有
268/// 「未选中也有头底」这一层。所以这里照 pinned_table 表头带同一套路子:
269/// **控件仍走 selectable_label**(可访问性标签 / 点击 / 键盘都不换),只是随后用
270/// painter 盖上头底 + 下划线,并重画文字(epaint 0.33 没有 z-order,后画的填充
271/// 会盖住先画的文字,所以文字必须重画 —— 与表头带同一处理)。
272fn tab_label(ui: &mut egui::Ui, selected: bool, text: impl Into<String>) -> egui::Response {
273    let ch = Chrome::of(ui.visuals(), crate::tokens::theme_of(ui.ctx()));
274    let text: String = text.into();
275    let resp = ui.selectable_label(selected, text.clone());
276    let r = resp.rect;
277    let radius = ui.visuals().widgets.inactive.corner_radius;
278    let font = ui.style().text_styles[&egui::TextStyle::Button].clone();
279    let p = ui.painter();
280    let (fill, ink, underline) = if selected {
281        (
282            ch.tab_fill,
283            ch.tab_text,
284            egui::Stroke::new(3.0_f32, ch.tab_underline), // qss 的 3px 那支
285        )
286    } else {
287        (
288            ch.band_fill,
289            ch.header_text,
290            egui::Stroke::new(2.0_f32, ch.line), // 未选中 tab 的 2px 下边线
291        )
292    };
293    p.rect_filled(r, radius, fill);
294    p.hline(r.x_range(), r.max.y, underline);
295    p.text(
296        r.center(),
297        egui::Align2::CENTER_CENTER,
298        text.as_str(),
299        font,
300        ink,
301    );
302    resp
303}
304
305/// 普通 Grid 的表头带(BD-UI-41)。
306///
307/// 与 `pinned_table` 的覆盖带同一套路子:头行照常在 grid 里占格(列宽由它量出来,
308/// 这是唯一权威源),随后 painter 盖上带底 + 下边线并重画文字(epaint 0.33 无 z-order,
309/// 填充盖字 → 文字必须重画)。
310///
311/// 为什么这三处不走 `pinned_table`:它们是**自动尺寸浮动窗里的短表**(账户清单 /
312/// 运维插件表 / 服务目录)。0.2.17 实锤(s39):`pinned_table` =
313/// `auto_shrink(false)` 滚动区吃满整块可用高 + 覆盖式表头带,在自动尺寸浮动窗里
314/// 两者凑不出正确高度 → 表行与其后的口径句互相压字。账户行最多几个,不需要固定
315/// 表头滚动面,只要那根带。
316///
317/// 0.2.29(BD-UI-44)把这条边界写死:数据监控表(行情/委托/成交/持仓/日志/合约/资金)
318/// 一律走表格层(0.4.1 BD-UI-55 = egui_table 的 `pinned_table`);自动尺寸浮动窗里的
319/// 短表一律 Grid + 这根带。资金表原来在第三类里,实测它和日志表共用同一块 tab 容器
320/// (不是浮动窗)→ 已并入表格层一侧。
321fn header_band(
322    ui: &mut egui::Ui,
323    heads: &[(egui::Rect, String)],
324    content_right: f32,
325) -> egui::Rect {
326    if heads.is_empty() {
327        return egui::Rect::NOTHING;
328    }
329    let ch = Chrome::of(ui.visuals(), crate::tokens::theme_of(ui.ctx()));
330    // 带的横向范围 = **整行**(这一格 / 这一窗的可用宽),不是表头文字矩形的并集。
331    // 根因(0.2.28 用户实看指出「运维窗表头背景没按整行、最后一列只有文字有背景」):
332    // `Response::rect` 是控件自己的矩形,不是列框(grid.rs:180-193 —— `next_cell`
333    // 给出列框,`align_size_within_rect` 按 LEFT_CENTER 对齐后返回子控件尺寸)。
334    // 表头 label 的 rect 宽 = 文字宽。原来按表头 rect 的并集算带 → 带只盖到
335    // 最后一列**表头文字**的右端,列内比它宽的部分(运维窗『操作』列的两个按钮、
336    // 服务目录的空实例列、资金格右侧)没有带 = 看上去断开一截。
337    // 口径照 Python:QHeaderView 铺满整个表视口;也与 `pinned_table` 那条带一致
338    // (那条铺的是 ScrollArea 的 inner_rect = 整格宽)。
339    let left = ui.max_rect().min.x;
340    // 0.2.30(BD-UI-47):带的右端取「可用宽」与**表体实际右端**的较大者。
341    // 浮动窗是自动尺寸的,而 `header_band` 在 Grid 之后才画 —— 令牌把控件交互宽
342    // 从 egui 缺省 128 收到 40 后,窗内 Grid 的实际宽度比窗的可用宽更宽(s69 实测
343    // 带 640 / 体表 727),带就盖不住最后一列。口径不变:带必须盖住整行。
344    let right = ui.max_rect().max.x.max(content_right);
345    // 带高与表格层**同一个数**(0.4.1 八期 BD-UI-61,用户问「表头的高度都一样吗」):
346    // 表格层带高 = 表头行高 `head_h`(26)+ `clip_rect_margin`(3)= 29;短表层原来取
347    // 「文字高 + 上下各半间距」= 22 → 同一屏里菜单窗的表头比主窗矮 7 px(探针实测 22.0 vs 29.0)。
348    // 现在按同一条公式算,并以**表头行的垂直中心**为带中心 → 带高一致、文字在带里居中。
349    let head_h = table_heights(ui).1;
350    let band_h = head_h + ui.visuals().clip_rect_margin;
351    let mid = heads[0].0.center().y;
352    let top = mid - band_h * 0.5;
353    let bottom = mid + band_h * 0.5;
354    let strip = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom));
355    let p = ui.painter();
356    p.rect_filled(strip, 0.0, ch.band_fill);
357    p.hline(
358        strip.x_range(),
359        strip.max.y,
360        egui::Stroke::new(1.0_f32, ch.line),
361    );
362    let font = ui.style().text_styles[&egui::TextStyle::Small].clone();
363    // 表头文字的锚定线 = **带的垂直中心**(0.4.1 七期 BD-UI-60)。旧口径取 galley 中心
364    // `(top + heads[0].0.max.y)/2`,比带中心高 2.5 px → 文字在带里偏上(实测上隙 4.5 / 下隙 8.5)。
365    let mid_y = (strip.min.y + strip.max.y) * 0.5;
366    for (rect, text) in heads {
367        p.text(
368            egui::pos2(rect.min.x, mid_y),
369            egui::Align2::LEFT_CENTER,
370            text.as_str(),
371            font.clone(),
372            ch.header_text,
373        );
374    }
375    strip
376}
377
378/// 判据侧读口(0.4.1 七期 BD-UI-60):这张表这一拍的「列宽总和」与「补宽目标宽」。
379///
380/// 目标宽 = 所在区域可用宽 − 这张表自己的滚动条占位。判据钉的是「补过之后 Σ ≥ 目标 9 成」
381/// —— 表宽 = 所在区域 100% 这条裁定在 kittest 里唯一不靠猜的读法(列线撤除后,列边界
382/// 在形状表里已经没有落点了)。
383pub fn table_fill_probe(ctx: &egui::Context, table_id: &str) -> (f32, f32) {
384    let fill_id = egui::Id::new(("table_fill", table_id));
385    ctx.data(|d| {
386        (
387            d.get_temp::<f32>(fill_id.with("stored")).unwrap_or(0.0),
388            d.get_temp::<f32>(fill_id).unwrap_or(0.0),
389        )
390    })
391}
392
393/// Grid 短表的网格线(0.4.1 五期 BD-UI-58,用户裁定「网格线在黑色背景下反而显得整齐」
394/// → 表格层与短表层同一口径:每行一根行线、每两列一根列线)。
395///
396/// - 行线 = 这一行内容底沿往下半个 `item_spacing.y`。Grid 的行间距就是 `item_spacing`
397///   (grid.rs:113 / 432),半个间距 = 两行正中;最后一行的行线就是这张表的底边线,
398///   与 `pinned_table` 里「行线画在行底」同一条不变式(那边行间距为 0)。
399/// - 列线 = 第 2..n 列表头矩形的左沿往左半个 `item_spacing.x`。Grid 格内是 LEFT 对齐
400///   (`align_size_within_rect`),控件左沿就是列左沿 → 半个间距正是两列正中。
401/// - 表头行那根下边线由 `header_band` 画,这里不重复画第二根。
402///
403/// `row_bottoms` = 各**表体行**在 `end_row` 前测到的 `ui.min_rect().max.y`。空态(0 行)
404/// 时它是空的:没有行线可画,列线只到表头带底(0.4.1 六期 BD-UI-59 起空态不画提示行,短表空态
405/// = 只有表头那一行)。
406fn grid_chrome(
407    ui: &egui::Ui,
408    band: egui::Rect,
409    heads: &[(egui::Rect, String)],
410    row_bottoms: &[f32],
411) {
412    if !band.is_positive() || heads.is_empty() {
413        return;
414    }
415    let ch = Chrome::of(ui.visuals(), crate::tokens::theme_of(ui.ctx()));
416    let sp = ui.spacing().item_spacing;
417    let p = ui.painter();
418    for &rb in row_bottoms {
419        p.hline(
420            band.x_range(),
421            rb + sp.y * 0.5,
422            // 行线 = `line_soft`(0.8.2,参考站 td{border-bottom:var(--st-line-soft)};
423            // 表头带那根下边线才是 `line`,由 `header_band` 画)。
424            egui::Stroke::new(1.0_f32, ch.line_soft),
425        );
426    }
427    // 列线(0.4.1 七期 BD-UI-60,用户裁定「表格里所有的列分割线都去掉,只要行的分割线」):
428    // 短表层这套自绘列线直接撤除,`heads` 从此只用来判「有没有表头」与定带的宽。
429}
430
431/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
432///
433/// v7 框架(0.4.1,BD-UI-54):TopBottomPanel(菜单) → TopBottomPanel(行情带) →
434/// SidePanel(下单)| CentralPanel(数据区 2×2 薄 QSplitter) → 弹窗。
435/// 0.4.0 的整窗 dock 拆除。官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、
436/// CentralPanel 最后加、Window 挂在所有面板之后。
437pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
438    apply_ui_style(ctx, shared.theme());
439    // 0.3.1(Wave D,BD-UI-49)退出确认:关窗先问一句(照 Python `closeEvent`)。
440    // 拦关闭用的是 egui 0.33 的 `ViewportCommand::CancelClose`;用户在窗里点「确认退出」
441    // 才置放行位并重发 `Close` —— 否则确认完也关不掉。
442    let close_requested = ctx.input(|i| i.viewport().close_requested());
443    if close_requested && !shared.exit_confirmed() && !shared.exit_confirm_open() {
444        ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
445        shared.open_exit_confirm();
446    }
447    // 0.3.2(Wave E,BD-UI-50)告警 toast 每帧收口(队列常驻在 UiShared,不是这里的局部变量)。
448    crate::toast::drain_and_show(ctx, shared);
449    if shared.exit_confirm_open() {
450        // 0.9.10 P2a(四面美化·关窗确认):tone 钮(取消=Ghost,确认退出=Confirm 红)、
451        // 一句正文带现场(在线账户数 = 退出的真实代价)、窗随内容自适应。
452        let online = shared
453            .accounts()
454            .iter()
455            .filter(|a| a.stage == crate::state::AccountStage::Assembled)
456            .count();
457        let body = |ui: &mut egui::Ui| {
458            ui.label(format!(
459                "确认退出?在线账户 {} 个,退出将断开全部连接并收场。",
460                online
461            ));
462            ui.horizontal(|ui| {
463                if op_button(ui, "取消", OpTone::Ghost).clicked() {
464                    shared.close_exit_confirm();
465                }
466                if op_button(ui, "确认退出", OpTone::Confirm).clicked() {
467                    shared.confirm_exit();
468                    shared.close_exit_confirm();
469                    ctx.send_viewport_cmd(egui::ViewportCommand::Close);
470                }
471            });
472        };
473        let mut probe = egui::Ui::new(
474            ctx.clone(),
475            egui::Id::new("exit_confirm_probe"),
476            egui::UiBuilder::new()
477                .global_scope(true)
478                .invisible()
479                .max_rect(egui::Rect::from_min_size(
480                    egui::pos2(0.0, 0.0),
481                    egui::vec2(48.0, 1.0e5),
482                ))
483                .layout(egui::Layout::top_down(egui::Align::Min)),
484        );
485        body(&mut probe);
486        let want = probe.min_rect().size() + egui::vec2(28.0, 44.0);
487        egui::Window::new("退出确认")
488            .collapsible(false)
489            .resizable(false)
490            .auto_sized()
491            .min_size(egui::vec2(260.0, 90.0))
492            .default_size(want)
493            .show(ctx, |ui| {
494                body(ui);
495            });
496    }
497    // ---- 菜单栏(顶层面板,最先 = 最外) ----
498    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
499        render_menu_bar(ui, shared);
500    });
501
502    // ---- 上带:行情带(全宽独占,可拖分隔条;缺省高 = Python v_splitter 30:70 分带) ----
503    egui::TopBottomPanel::top("market_band")
504        .resizable(true)
505        .default_height(crate::tokens::MARKET_BAND_HEIGHT)
506        .show(ctx, |ui| {
507            // 外层 ScrollArea auto_shrink(false) = resizable 面板吃满前提(BD-UI-27
508            // 口径;s3 实锤:撤掉后空态带塌成一行,2×2 行位全漂移)。内层
509            // pinned_table 自己会收缩适配 → 外层滚动条永不出现。
510            egui::ScrollArea::both()
511                .id_salt("market_band_scroll")
512                .auto_shrink([false, false])
513                .show(ui, |ui| {
514                    render_market_band(ui, shared);
515                });
516        });
517
518    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget setFixedWidth(300)) ----
519    egui::SidePanel::left("order_side")
520        .resizable(true)
521        .default_width(crate::tokens::ORDER_SIDE_WIDTH)
522        .show(ctx, |ui| {
523            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
524            egui::ScrollArea::vertical()
525                .id_salt("order_side_scroll")
526                .auto_shrink([false, false])
527                .show(ui, |ui| {
528                    render_order_panel(ui, shared);
529                    ui.separator();
530                    render_book_frame(ui, shared);
531                });
532        });
533
534    // ---- 下带右侧:数据区 2×2(薄 QSplitter 两刀;份额可拖,态在 UiShared) ----
535    egui::CentralPanel::default().show(ctx, |ui| {
536        render_data_area(ui, shared);
537    });
538
539    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
540    render_windows(ctx, shared);
541}
542
543/// 行情带 = 当前所的行情表 + 六所固定 tab 条(0.4.1 BD-UI-54 回归原生容器;
544/// 「每交易所一格」= 六个 tab,每个 tab 都有自己的表与空态,s73 口径不变)。
545///
546/// 0.4.1 用户裁定(2026-10-22):**tab 条放在表下面**(原来在表上面)。
547/// 这是对 Python 侧 `QTabWidget` 缺省 tabPosition=North 的一处偏离,记 DESIGN。
548/// 判据面不受影响:六所 tab 序钉(V2)与「行情带横跨全宽」钉(s3)取的都是
549/// tab 标签在场 + 格内容左沿/高度关系,与 tab 条在表的哪一侧无关。
550/// 交易所 tab 名 = **中文名**(0.4.1 九期 BD-UI-62,用户裁定「不要显示交易所的代码,
551/// 而是显示交易所的名字」)。叫法照 Python 侧(`xt_gateway.py:1089-1094` 与
552/// `datafeed/ak/futures_member_pos.py:14-19` 的映射:中金所=CFFEX / 上期所=SHFE /
553/// 郑商所=CZCE / 大商所=DCE / 广期所=GFEX,INE = 能源中心)。
554pub fn exchange_tab_name(ex: ek_data::constant::Exchange) -> &'static str {
555    use ek_data::constant::Exchange as E;
556    match ex {
557        E::SHFE => "上期所",
558        E::DCE => "大商所",
559        E::CZCE => "郑商所",
560        E::CFFEX => "中金所",
561        E::GFEX => "广期所",
562        E::INE => "能源中心",
563        // 行情带只有上面六所(`state::MARKET_ORDER`);其余变体回落线上代码。
564        other => other.as_str(),
565    }
566}
567
568fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
569    let active = shared.selected_exchange();
570    let active_ex = crate::state::MARKET_ORDER
571        .iter()
572        .copied()
573        .find(|e| e.as_str() == active)
574        .unwrap_or(crate::state::MARKET_ORDER[0]);
575    // **先给 tab 条留出高度,再让表吃剩下的**(0.4.1 九期 BD-UI-62,用户实看:「表格被下面
576    // 标签给挡住了,只有滚动才能看到」)。根因:表(内层 ScrollArea)先分配 = 把整块面板高
577    // 吃干,tab 条排在它后面就被推到面板底边之外(探针实测 1280×860:面板底 ≈275,
578    // tab 标签落在 y 274..300 = 面板之外,还与数据区「委托」tab 头 278..304 压在一起)。
579    // tab 条是固定条,不该跟表格滚动抢地方。
580    // 预留高度照 `tab_label` 真正用的那一档:`selectable_label` 走 `TextStyle::Button`
581    // + `button_padding`(不是 Small —— 用 Small 会少算,窗口一窄就又开始外溢)。
582    let tab_h = ui.text_style_height(&egui::TextStyle::Button)
583        + ui.spacing().button_padding.y * 2.0_f32
584        + 4.0_f32;
585    let avail = ui.available_size();
586    let table_rect = egui::Rect::from_min_size(
587        ui.cursor().min,
588        egui::vec2(avail.x, (avail.y - tab_h).max(40.0)),
589    );
590    ui.scope_builder(egui::UiBuilder::new().max_rect(table_rect), |ui| {
591        // 表头带的上隙(`clip_rect_margin`)要在子 ui 里留出地方:子 ui 的 clip = 这块
592        // max_rect,若表从 cursor 顶开始画,带顶那 3 px 会被裁掉(带高 26 而不是 29,
593        // s79「所有表头带同一档」当场红)。
594        ui.add_space(ui.visuals().clip_rect_margin);
595        render_market_pane(ui, shared, active_ex);
596    });
597    ui.horizontal(|ui| {
598        for ex in crate::state::MARKET_ORDER {
599            if tab_label(ui, ex == active_ex, exchange_tab_name(ex)).clicked() {
600                shared.set_selected_exchange(ex.as_str());
601            }
602        }
603    });
604}
605
606/// 数据区 2×2 = 上下(顶部:委托|未成交 | 成交;底部:持仓 | 资金|日志),
607/// 两刀都是可拖的薄 QSplitter(份额在 `shared.data_split`,恢复布局回 0.5)。
608fn render_data_area(ui: &mut egui::Ui, shared: &UiShared) {
609    let split = shared.data_split();
610    // 0.4.1 九期 BD-UI-62:2×2 改走 `splitters::quad`(一根贯穿横线 + 一根贯穿竖线)。
611    // 旧口径 = `split_v` 上下分完再在每排里各来一刀 `split_h` → 「竖刀」实为两条独立线段,
612    // 悬 / 拖只有被碰的那半亮(用户实看)。份额仍是同一对 `DataSplit { row, col }`。
613    crate::splitters::quad(
614        ui,
615        egui::Id::new("ek_data_quad"),
616        split.row,
617        split.col,
618        |row| {
619            shared.set_data_split(crate::state::DataSplit {
620                col: split.col,
621                row,
622            });
623        },
624        |col| {
625            shared.set_data_split(crate::state::DataSplit {
626                col,
627                row: split.row,
628            });
629        },
630        |ui| render_topleft_group(ui, shared),
631        |ui| render_trades_group(ui, shared),
632        |ui| render_positions_group(ui, shared),
633        |ui| render_bottomright_group(ui, shared),
634    );
635}
636
637/// 委托|未成交 组(Python tab_widget1:两 tab 同一容器)。
638fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
639    let tab = shared.topleft_tab();
640    ui.horizontal(|ui| {
641        if tab_label(ui, tab == TopLeftTab::Orders, TopLeftTab::Orders.label()).clicked() {
642            shared.set_topleft_tab(TopLeftTab::Orders);
643        }
644        if tab_label(ui, tab == TopLeftTab::Active, TopLeftTab::Active.label()).clicked() {
645            shared.set_topleft_tab(TopLeftTab::Active);
646        }
647    });
648    render_orders_table(ui, shared, tab == TopLeftTab::Active);
649}
650
651/// 资金|日志 组(Python tab_widget4;Account 分支 = 0.4.0 修好的「空态带表头」版)。
652fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
653    let tab = shared.bottomright_tab();
654    ui.horizontal(|ui| {
655        if tab_label(
656            ui,
657            tab == BottomRightTab::Account,
658            BottomRightTab::Account.label(),
659        )
660        .clicked()
661        {
662            shared.set_bottomright_tab(BottomRightTab::Account);
663        }
664        if tab_label(
665            ui,
666            tab == BottomRightTab::Logs,
667            BottomRightTab::Logs.label(),
668        )
669        .clicked()
670        {
671            shared.set_bottomright_tab(BottomRightTab::Logs);
672        }
673    });
674    match tab {
675        BottomRightTab::Account => render_funds_pane(ui, shared),
676        BottomRightTab::Logs => render_logs(ui, shared),
677    }
678}
679
680// 0.4.1(BD-UI-54):数据区组的容器 = 薄 QSplitter 的两块内容区(sql)。格边框与
681// 分隔线走色板令牌(`splitters::run_split` 只吃 `tokens::shadcn`,V5 钉住);
682// 0.3.0 dock 容器曾接管过这层,0.4.1 拆 dock 后归位。
683// 0.2.30 那条教训仍然成立并记在 DESIGN.md:egui 0.33.3 的 `Frame` 没有 `min_size`,
684// `Frame::paint` 的描边按**内容** min_rect 算(frame.rs:486)——内容少的格边框会跟着缩。
685
686// 表头固定表(BD-UI-34 用户裁定「每个表的表头都要固定」;0.2.29 BD-UI-44 起换成表格库,
687// 0.4.1 BD-UI-55 表格库 = `egui_table 0.7`,用户裁定「表格使用 egui_table」)。
688//
689// 为什么要表格库(实拍毛病 2/3/5,对照 Python 的 QTableView):
690// - **列宽跟内容**:手绘 Grid 的列宽只能取各格 `Response::rect` 的最大值,而
691//   `Response::rect` 是控件矩形不是列框(0.2.28 实锤,egui grid.rs:180-193)
692//   → 列宽只能"差不多",数字列对不齐。表格库的列宽是库自己量出来的(egui_table:
693//   `Column::range` 夹 `max_column_widths`,table.rs:816-843 写回 `ColumnState.width`)。
694// - **表头天生固定**:表头行画在体表滚动区之外(egui_table `Table::show` 先 `header_ui`
695//   再 `SplitScroll`,table.rs:470-537)→ 0.2.8 起那块「ScrollArea 之后重画覆盖带 +
696//   重画表头文字」的 DIY 全撤(当年 DIY 是因为 egui 没有表;scroll_area.rs:412
697//   那句「painting a sticky header on top of it」就是 DIY 的出处)。
698// - **横向溢出**:成交表 14 列比格子宽,Python 出横向滚动条(毛病 5)。egui_table 的
699//   `SplitScroll` 自带横 + 纵两条滚动条:表头跟着体表一起横滚(不错位),竖滚仍归表体
700//   (表头不动)。
701// - **选中 / 悬停底色吃主题**:egui_table 不提供行选中(全库无 `set_selected`)→ 底色由
702//   delegate 的 `row_ui` 自己画,色仍取 `visuals.selection.bg_fill` /
703//   `widgets.hovered.bg_fill`,且按行带全宽画 = 整行底色连续
704//   → 手绘 `paint_row_bg` 那支硬编码半透明蓝撤掉(与 0.2.27「斑马纹跟主题」同口径)。
705// - **网格线(0.4.1 五期 BD-UI-58 起:全部表统一都有)**:列线 = 可拖列边界那根线
706//   (egui_table `Column::resizable` + `chrome().line`),行线 = delegate 每行的行底。
707//   旧口径是「只有合约表开网格」(Python `widget.py:367` 的 `setShowGrid(False)` 只加在
708//   交易表上,合约表 `widget.py:2423`/`2621` 没关,qss `gridline-color:#455364`)——
709//   用户 2026-10-22 实看后裁定撤掉这条分叉:「网格线在黑色背景下比较暗,有横向竖向这些线
710//   反而显得整齐」→ 交易表跟合约表一样都画,`GridLines` 这个开关随之退役。
711//
712// body 闭包在表体内执行(行循环归闭包);行高由本模块统一算,body 只写每格内容。
713// pub(crate):`pinned_table_impl_cols` 是 pub(crate)(日志表字体量宽入口要用)。
714
715/// 行高 / 表头高 = 字号 + 6。Python QTableView 的 sizeHint ≈ 字体高 + 4~6;
716/// 0.2.28 之前 Grid 自然行高在 kittest 实测行间距 21 = 正文 14.7 + 6,同值。
717pub(crate) fn table_heights(ui: &egui::Ui) -> (f32, f32) {
718    let st = ui.style();
719    // 行高必须取「文字高 + 6」与**控件交互高令牌**的较大者。格里有钮 / 勾选框时
720    // 表格层的实际行高 = max(请求值, 格内最高控件):请求值 22 < 实际 26 时,
721    // 行线公式按假行高算 —— 0.2.30 实锤:行线画在 y=514 而第 3 行文字中心在 511
722    // = 0.2.28 治过的毛病 8(行线压到文字中心上)复发。别把这个 max 拆掉。
723    let floor_h = crate::tokens::INTERACT_HEIGHT;
724    (
725        (st.text_styles[&egui::TextStyle::Body].size + 6.0).max(floor_h),
726        (st.text_styles[&egui::TextStyle::Small].size + 6.0).max(floor_h),
727    )
728}
729
730// ================= egui_table 0.7.0 适配层(BD-UI-55,用户裁定「表格使用 egui_table」) =========
731//
732// egui_table 的形制是「拉」:建 `Table`(列/表头/行数)+ `TableDelegate`(表头/行/格的
733// 逐个绘制回调);egui_extras::TableBuilder 的形制是「推」(调用点闭包直接喂行)。
734// 本层把推改成拉:调用点的 body 闭包**原样保留**(行循环 + 每格内容),只是行收进
735// `PinBody`/`PinRow` 的延迟画笔,show 时由 delegate 逐格执行 —— 每张表调用点只多写
736// `selected` 一个参数与 `pin_cell` 一层包装。
737//
738// 每格左内边距 = 6px(egui_table 离 battery 之外不加 margins,old TableBuilder 的
739// item_spacing 缩进由 Frame::inner_margin 补回来,不然文字贴列边框)。
740
741/// 单格延迟画笔(行收集物;腰上 `'b` = pinned_table_impl 的本次调用)。
742/// **FnMut 不是 FnOnce**:egui_table 首帧在同一帧里把每格跑两遍(全量量内容的
743/// invisible sizing pass + 真绘 pass,库自己 `request_discard`)—— FnOnce 在
744/// sizing pass 里就被消费掉,真绘 pass 会全空(0.4.1 实锤:所有格文字消失)。
745/// 格内动作要幂等(select_row/set_order_form 等同帧重放语义不变;egui 的
746/// request_discard 本来就是「整帧重来」)。
747/// 第二个参数 = 这一格的上下文(满格命中层),由 delegate 的 `cell_ui` 造,见 `PinCellCtx`。
748pub(crate) type PinCell<'b> = Box<dyn FnMut(&mut egui::Ui, &PinCellCtx) + 'b>;
749
750/// 一格真实绘制时由 delegate 递给调用点的上下文(BD-UI-55)。
751pub(crate) struct PinCellCtx {
752    /// 满格命中层:在**格那一层**注册的响应(`Ui::interact`,矩形 = 整格)。
753    ///
754    /// 为什么不是 `ui.allocate_rect(ui.max_rect(), ..)`(BD-UI-55 最大拦路虎,已修):
755    /// `allocate_rect` 的口径是「忽略布局,这块地归我」—— 布局 cursor 被推到整格之外,
756    /// 之后格内的内容排到**下一格**去。实锤(临时探针 + vendor 出来的 egui 打点,
757    /// 脚手架已随本次提交拆除,读数留档在交接报告 §4.1 / 交接报告主文 §21.3):
758    /// 格 clip `[901..1001]`,格内 label 的实际矩形 `[1003..1088]` = 错开一整列宽 →
759    /// `Label` 的绘制门 `ui.is_rect_visible(response.rect)` 判假 → 表体文字既不画、
760    /// 也不进语义树。`interact` 不推进布局(ui.rs:1131 只 `create_widget`),
761    /// 满格命中与格内真控件的响应照旧两层并立 = 0.2.29「两边都看」口径不变。
762    ///
763    /// `interact_rect` = 注册处 ui 的 clip ∩ 矩形(ui.rs:1140),所以这颗必须注册在
764    /// **格那一层**(clip = 整格),不能注册在 Frame 内层(clip 已被内边距缩掉 6px)。
765    pub hit: egui::Response,
766}
767
768/// 一行(PinBody 收集物)。行高统一走 delegate 的 `row_h`(`table_heights`),
769/// egui_table 的行是相邻接等高带 —— `body.row(h, ..)` 的 h 现在只是调用点形状兼容,
770/// 不再逐行生效(0.4.1 适配层口径)。
771struct PinRow<'b> {
772    selected: bool,
773    cells: Vec<PinCell<'b>>,
774}
775
776/// 体表 push 收集器(替代 `egui_extras::TableBody` 的角色;BD-UI-55)。
777#[derive(Default)]
778pub(crate) struct PinBody<'b> {
779    rows: Vec<PinRow<'b>>,
780}
781
782impl<'b> PinBody<'b> {
783    /// 与旧 `body.row(h, |mut row| ..)` 同形,多一个 `selected`
784    /// (选中底色从构建时的 `set_selected` 改为收集物,绘制时 `row_ui` 画)。
785    pub(crate) fn row(&mut self, _h: f32, selected: bool, add: impl FnOnce(&mut PinRowAdd<'b>)) {
786        let mut r = PinRowAdd {
787            selected,
788            cells: Vec::new(),
789        };
790        add(&mut r);
791        self.rows.push(r.finish());
792    }
793}
794
795/// 行头的 `PinRow::row` 闭包参数,负责收格。
796pub(crate) struct PinRowAdd<'b> {
797    selected: bool,
798    cells: Vec<PinCell<'b>>,
799}
800
801impl<'b> PinRowAdd<'b> {
802    /// 一格,与旧 `row.col(|ui| ..)` 同形(形态不同点:内容延迟到体表真实绘制时跑,
803    /// 且首帧同帧跑两遍 —— FnMut,见 `PinCell`;多出来的第二个参数 = 这一格的
804    /// `PinCellCtx`,由 delegate 的 `cell_ui` 造)。
805    pub(crate) fn cell(&mut self, make: impl FnMut(&mut egui::Ui, &PinCellCtx) + 'b) {
806        self.cells.push(Box::new(make));
807    }
808    fn finish(self) -> PinRow<'b> {
809        PinRow {
810            selected: self.selected,
811            cells: self.cells,
812        }
813    }
814}
815
816/// 与旧 `tcell(row, right, make)` 等价的一格:格响应 = 整格可点那一层
817/// (`PinCellCtx::hit`,在格那一层注册的 `interact`),格内控件自己的响应跟随
818/// `CellOutcome::response()`;命中判定(旧宏里 `cell_r.clicked() || inner_r.clicked()`)
819/// 由这两个响应合成:返回 `(cell_r, inner_r)`,闭包内接着写行动作 ——
820/// 与 0.2.29 的「两边都看」完全同口径(egui 一颗点击只发给一个响应,见 `CellOutcome`)。
821///
822/// **0.8.0 对齐统一:撤掉 `right_align` 形参。** 旧口径六张表各有一份 `RIGHT_COLS`
823/// (行情 10 / 持仓 11 / 委托 4 / 成交 3 / 资金 3 / 合约 2 = 35 个右对齐格位)经
824/// `RIGHT_COLS.contains(&$col)` 传进来,而表头 100% 左对齐 —— 用户报的
825/// 「表头和表的内容对齐方式不一致」的真身。裁定 D = **全左对齐** → 右对齐那半退役,
826/// 「数字成列」由 `tokens::num_text`(等宽数字)那一半守(代价与口径见 `num_text` 注释)。
827pub(crate) fn pin_cell<R: CellOutcome>(
828    ui: &mut egui::Ui,
829    ctx: &PinCellCtx,
830    make: impl FnOnce(&mut egui::Ui) -> R,
831) -> (egui::Response, Option<egui::Response>) {
832    let inner = make(ui);
833    (ctx.hit.clone(), inner.response())
834}
835
836/// SplitScroll 的**退化象限**判定(BD-UI-55 实查;`row_ui` 用)。
837///
838/// `num_sticky_cols = 0` 时左上/左下象限的 rect 恒零宽,可 egui_table 无条件调四个
839/// 象限的 delegate(split_scroll.rs:79-151)→ 左下象限里每一行都会调一次 `row_ui`。
840/// 那里画不出任何东西(裁剪矩形零面积 → tessellator.rs:1367 直接丢),但选中底色 /
841/// 行线仍会被登记进本帧形状表 —— 判据读的正是形状表,所以就地不画。
842///
843/// 判据 = 「clip 与格矩形**共边**(宽恰为 0)而格矩形本身正」。这与「格在视口之外」
844/// (clip 与格矩形不相交 → 宽为**负**)是两种不同的退化,后者不能砍:首帧列宽量测靠的
845/// 就是「全列都跑一遍」(table.rs:633-637 的 `do_full_sizing_pass` → `col_range = 0..len`)。
846/// 表头那一侧这两种退化在几何上长得一样,分不开 → `header_cell_ui` 改用调用序判据。
847fn quadrant_is_degenerate(ui: &egui::Ui) -> bool {
848    let c = ui.clip_rect();
849    let m = ui.max_rect();
850    (c.width() == 0.0 && m.width() > 0.0) || (c.height() == 0.0 && m.height() > 0.0)
851}
852
853impl egui_table::TableDelegate for PinDelegate<'_, '_> {
854    fn header_cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::HeaderCellInfo) {
855        // 表头在 SplitScroll 里被画**两遍**:调用序 = right_bottom → left_top → right_top
856        // → left_bottom(split_scroll.rs:79-151)。`num_sticky_cols = 0` → left_top 那一遍
857        // 在零宽象限里:形状画不出(裁剪退化 → tessellator.rs:1367 直接丢),可 egui 的
858        // `Rect::intersects` 用 `<=`(零宽 clip 与文字矩形「相切」也算相交)→
859        // `is_rect_visible` 判真 → 同一颗表头在 AccessKit 语义树里两颗(0.4.1 实锤:
860        // s3/s26/s63/s71 的「成交金额 / 成交号 / 信息 / 最小变动」各两颗)。
861        //
862        // 判据用**调用序**而不是几何:退化象限里 clip 与格矩形相交时宽恰为 0、不相交时
863        // 为负,而「格在视口之外」的 clip 也是负 —— 几何上分不开(本轮实撞:按几何判
864        // 要么漏掉 left_top 的第 2 列起,要么把视口外的表头一起砍掉)。left_top 恒在
865        // right_top 之前 → 本 pass 里这一列的第一次调用就是退化象限,不画。
866        //
867        // 第二遍(视口那一层)**照画,视口外的列也画**:首帧列宽量测靠的就是「全列都跑
868        // 一遍」(table.rs:633-637 的 `do_full_sizing_pass` → `col_range = 0..len`);
869        // 漏掉表头,那一列就只量到体表内容(0.4.1 实锤:「成交金额」列 24px 而表头文字
870        // 57px,表头文字压进隔壁列)。视口外的格 `is_rect_visible` 判假 → 不画也不建
871        // 语义节点,所以照画不会带回双节点。
872        let i = cell.col_range.start;
873        let Some(seen) = self.header_drawn.get(i) else {
874            return;
875        };
876        let seen = *seen;
877        if !seen {
878            self.header_drawn[i] = true;
879            return;
880        }
881        // 表头格:文字(0.4.0 前的形制 == strong + small)+ 可点(排序入口)。
882        // 带色底与铺满全宽的带已在 pinned_table_impl 里画好(在表 show 之前 =
883        // z 序在下),这里只画文字 + register 点击。
884        let i = cell.col_range.start;
885        let mark = match self.sort {
886            Some((c, asc)) if c == i => {
887                if asc {
888                    " ▲"
889                } else {
890                    " ▼"
891                }
892            }
893            _ => "",
894        };
895        // 表头文字上下居中(0.4.1 七期 BD-UI-60,用户裁定「表格里的文字包括抬头都要上下居中」)。
896        // 旧口径:库给的表头格 child ui 起笔在格顶 → 实测带高 29 时文字视觉盒上隙 11 / 下隙 4。
897        // 光靠格内起笔调不出来(galley 的**视觉盒**比它的行盒偏下几 px)→ 直接把这一颗文字
898        // 按带的垂直中心摆:在 `band_mid ∓ 行高/2` 处开一个与格同宽的子 ui。
899        let cell = ui.max_rect();
900        let gh = ui.text_style_height(&egui::TextStyle::Small);
901        // **表头文字起笔 = 格左缘 + 格内内边距**(0.8.0 对齐统一,判据 s91):体表格每一格
902        // 套了 `Frame::inner_margin(left = CELL_INSET_X)`(见 `cell_ui`),旧口径表头从
903        // `cell.min.x` 起笔 → 表头比表体内容**靠左 6px**,就是用户报的「表头和表的内容
904        // 对齐方式不一致」。两处吃同一个常量,逐列左沿对齐(竖线共用)。
905        let target = egui::Rect::from_min_size(
906            egui::pos2(
907                cell.min.x + crate::tokens::CELL_INSET_X,
908                self.band_mid - gh * 0.5,
909            ),
910            egui::vec2(cell.width() - crate::tokens::CELL_INSET_X, gh),
911        );
912        ui.scope_builder(egui::UiBuilder::new().max_rect(target), |ui| {
913            // **表头文字色 = 当前档的 text3**(0.8.2 补的落点,判据 `s94`):参考站
914            // `th{color:var(--st-ink-muted)}`。0.8.0 只把这一支做进了 `Chrome` 与 Grid 短表
915            // 的覆盖带,六张表的表头在这里画、没吃它 → 表头与表体同色(用户复看「表头和表格里
916            // 的字的颜色就不对」的实底)。`.strong()` 只加字重,颜色显式给。
917            ui.label(
918                egui::RichText::new(format!("{}{mark}", self.headers[i]))
919                    .strong()
920                    .small()
921                    .color(crate::tokens::theme_of(ui.ctx()).palette().text3),
922            );
923        });
924        // 点表头 = 排序。id 用「这颗表头格自己的 ui id」:egui_table 给表头格的 child ui
925        // 带 `id_salt(("header", row_nr, group_index))`(table.rs:586-593)→ 列间唯一、
926        // 帧间稳定;旧口径(`ui.id().with(("ek_table_header", table_id, i))`)的等价物。
927        let id = ui.id().with(("hdr_click", i));
928        if ui
929            .interact(ui.max_rect(), id, egui::Sense::click())
930            .clicked()
931        {
932            self.clicked.get_or_insert(i);
933        }
934    }
935
936    fn row_ui(&mut self, ui: &mut egui::Ui, row_nr: u64) {
937        // 左下象限(零宽,`num_sticky_cols = 0`)也会逐行调到这里 —— 与表头同理,
938        // 别让它把选中底色 / 行线登记进形状表。
939        if quadrant_is_degenerate(ui) {
940            return;
941        }
942        let r = ui.max_rect();
943        // **整行**底色,不是「有内容的那几格」(0.4.1 九期 BD-UI-62,用户裁定「我们选的是整行,
944        // 包括空行的也一起给它标上颜色」)。库给的行矩形 = `col_x[0]..=col_x[last]`
945        // (table.rs:689-690) = 所有列的宽度并集;列总宽窄于可视区时(日志表两列、接管版
946        // 列宽的表),行矩形右边就比可视区短一截 → 高亮只到最后一列右边,右边空余不亮。
947        // 补法:右边至少铺到「与表头带同宽」(带 = 可视区宽,`band_x` 构造时已捕获)。
948        let band_w = self.band_x.max - self.band_x.min;
949        let fill = egui::Rect::from_min_max(
950            egui::pos2(r.min.x, r.min.y),
951            egui::pos2(r.max.x.max(r.min.x + band_w), r.max.y),
952        );
953        // 选中底色(0.2.29 口径 = 主题 `selection.bg_fill`,不再手绘蓝)。
954        if let Some(row) = self.rows.get(row_nr as usize) {
955            if row.selected {
956                ui.painter()
957                    .rect_filled(fill, 0.0, ui.visuals().selection.bg_fill);
958            } else if ui.rect_contains_pointer(fill) {
959                // 悬停判定也用 `fill`(整行)而不是 `r`(内容宽):否则「行尾空余」这一带
960                // 鼠标放进去根本不会亮 —— 那等于只把颜色补宽了,裁定没做全。
961                ui.painter()
962                    .rect_filled(fill, 0.0, ui.visuals().widgets.hovered.bg_fill);
963            }
964            // 行线(0.4.1 五期 BD-UI-58:全部表都有,不再是合约表专属)。
965            // x 横跨**表体可视宽**而不是这一行的内容宽:旧口径取 TableBuilder body 的
966            // `max_rect().x_range()` = 横向 ScrollArea 内层 ui 的可用宽 = 整张表宽;
967            // 按单行内容宽画,列内容窄于面板时行线只到最后一列右边,与表头带不同宽
968            // (0.4.1 实锤:合约表行线 427..825,带 423..991 → 判据里 13 行数成 1 根)。
969            // 行线(0.4.1 五期 BD-UI-58:全部表都有;六期 BD-UI-60 起它是**唯一**的网格线,
970            // 列线按用户裁定撤除)。颜色取 delegate 构造时捕获的 `line` —— 表 scope 里
971            // `noninteractive.bg_stroke` 已被改成透明(见 `pinned_table_impl`),在这里现取
972            // 会连行线一起抹掉。
973            ui.painter()
974                .hline(self.band_x, r.max.y, egui::Stroke::new(1.0_f32, self.line));
975        }
976    }
977
978    fn cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::CellInfo) {
979        // 每格左内边距 6(注释见模块头);按列取出延迟画笔就地执行(FnMut,
980        // 首帧两遍都跑;不 replace —— FnOnce 那套会让第二遍全空,0.4.1 实锤)。
981        let Some(row) = self.rows.get_mut(cell.row_nr as usize) else {
982            return;
983        };
984        let Some(slot) = row.cells.get_mut(cell.col_nr) else {
985            // 空态行只推了首格(§4.2 低优):缺格静默跳过。
986            return;
987        };
988        // 满格命中层(见 `PinCellCtx::hit`):注册在格这一层 —— 这层的 clip 就是整格,
989        // `interact` 的 interact_rect = clip ∩ 整格 = 整格。id 用这层自己的 ui.id():
990        // egui_table 给格 child ui 带 `id_salt((row_nr, col_nr))`(table.rs:713-720)
991        // → 格间唯一、帧间稳定(旧 `TableRow::col` 那颗格响应的等价物)。
992        let hit = ui.interact(
993            ui.max_rect(),
994            ui.id().with("pin_cell"),
995            egui::Sense::click(),
996        );
997        let ctx = PinCellCtx { hit };
998        // 格内左右内边距 = `tokens::CELL_INSET_X`(0.8.0 对齐统一:表头文字起笔吃的是
999        // **同一个常量**,旧口径两处各写 6 → 表头比表体靠左 6px = 用户报的对不齐)。
1000        let inset = crate::tokens::CELL_INSET_X as i8;
1001        egui::Frame::new()
1002            .inner_margin(egui::Margin {
1003                left: inset,
1004                right: inset,
1005                top: 0,
1006                bottom: 0,
1007            })
1008            .show(ui, |ui| slot(ui, &ctx));
1009    }
1010
1011    fn default_row_height(&self) -> f32 {
1012        self.row_h
1013    }
1014}
1015
1016/// pinned_table 用的 delegate(见模块头说明;字段随 `pinned_table_impl` 收集)。
1017struct PinDelegate<'d, 'b> {
1018    headers: &'d [&'d str],
1019    sort: Option<(usize, bool)>,
1020    rows: &'d mut [PinRow<'b>],
1021    row_h: f32,
1022    clicked: Option<usize>,
1023    /// 本 pass 里每列表头已画过没有(表头在 SplitScroll 里被画两遍,见 `header_cell_ui`)。
1024    header_drawn: Vec<bool>,
1025    /// 表头带的 x 范围 = 表体可视宽。行线按它画,不按单行的内容宽(旧口径,见 `row_ui`)。
1026    line: egui::Color32,
1027    band_x: egui::Rangef,
1028    /// 表头带的垂直中心(0.4.1 七期 BD-UI-60:表头文字按它上下居中,见 `header_cell_ui`)。
1029    band_mid: f32,
1030}
1031
1032/// 行高/表头高 = 字号 + 6(`table_heights` 见上;两值直接进 `HeaderRow`/行收集)。
1033///
1034/// 四个入口都要带自由 `'p`:行收集物 `PinBody<'p>` 的行延迟画笔寿命 = **调用方帧**
1035/// (add_body 闭包里的格闭包借用外层局部 —— `'p` 由调用点挑一个盖住它们的生命周期)。
1036/// 写成匿名的 `PinBody<'_>`(HRTB ⇒ for<'x> 任一寿命)会让借用逃逸出函数体
1037/// (E0521/E0597,本轮实撞)。
1038pub(crate) fn pinned_table<'p>(
1039    ui: &mut egui::Ui,
1040    table_id: &str,
1041    headers: &[&str],
1042    body: impl FnOnce(&mut PinBody<'p>, f32),
1043) -> Option<usize> {
1044    pinned_table_impl(ui, table_id, headers, None, body)
1045}
1046
1047/// 0.3.1(Wave D,BD-UI-49)可排序表:`sort` = 当前排序键,返回**这一拍被点过的表头列**。
1048/// 排序键由调用方从 `UiShared::table_sort` 取、点完表头再写回 `toggle_table_sort`.
1049fn pinned_table_sorted<'p>(
1050    ui: &mut egui::Ui,
1051    table_id: &str,
1052    headers: &[&str],
1053    sort: Option<(usize, bool)>,
1054    body: impl FnOnce(&mut PinBody<'p>, f32),
1055) -> Option<usize> {
1056    pinned_table_impl(ui, table_id, headers, sort, body)
1057}
1058
1059/// pinned_table 的共用实现(0.4.1 BD-UI-55 起换 egui_table 0.7.0):
1060/// - **表头天生粘顶**(egui_table `headers` 半 sticky row,0.2.6 DIY 覆盖带撤除)。
1061/// - **横向溢出**:SplitScroll 的右下象限 = 横向滚动区,表头(右象限)与体表同滚不错位,
1062///   表头带照画全宽(带底画在 show 之前 = z 序最底)。
1063/// - **列宽**:库的首帧全量量测(do_full_sizing_pass)+ 之后冻结可持续拖/resizable 列。
1064///   没有「每帧 auto_size_this_frame」:那会让 delegate(连同我们的action闭包)每帧跑两遍
1065///   —— egui 的 sizing pass = request_discard 重跑一遍帧,行动作一切两半(0.4.1 实查:
1066///   table.rs:384-443 的 discard 挂 per-column,content 变化重测路径库注释自己也标
1067///   TODO)。内容超宽的列(日志正文)由调用方传列宽。
1068/// - **行交互**:egui_table 不给格/行级 Response —— 每格用 `pin_cell` 自己建「满格
1069///   allocate + 控件响应」的两层判定(与 0.2.29 的旧「格响应+控件响应」等价)。
1070/// - **选中/悬停**:delegate 的 `row_ui`(行带全宽;旧 TableBuilder 的 expand 缝补
1071///   不再需要 —— egui_table 行与行零间隙)。
1072/// - **斑马纹依旧没有**(0.2.27 起跟主题 = visuals.striped = false)。
1073fn pinned_table_impl<'p>(
1074    ui: &mut egui::Ui,
1075    table_id: &str,
1076    headers: &[&str],
1077    sort: Option<(usize, bool)>,
1078    add_body: impl FnOnce(&mut PinBody<'p>, f32),
1079) -> Option<usize> {
1080    pinned_table_impl_cols(ui, table_id, headers, sort, None, add_body)
1081}
1082
1083/// 空态列宽(0.4.1 五期 BD-UI-58,用户裁定「按表头分配各列比例,等有数据再按数据调整」):
1084/// 按**表头文字宽的比例**把这一拍的可用宽分掉。表头总宽 > 可用宽时不放大(照内容走横向
1085/// 滚动,与 Python `ResizeToContents` 同形)。`+12` = 格左右内边距 6+6(`cell_ui` 的
1086/// `inner_margin`),下限 24 = `Column::range` 下限。
1087fn header_proportional_widths(ui: &egui::Ui, headers: &[&str], available: f32) -> Vec<f32> {
1088    let head_font = ui.style().text_styles[&egui::TextStyle::Small].clone();
1089    let head_w = |s: &str| -> f32 {
1090        let g = ui.painter().layout_no_wrap(
1091            s.to_string(),
1092            head_font.clone(),
1093            egui::Color32::TRANSPARENT,
1094        );
1095        (g.size().x + 12.0).max(24.0)
1096    };
1097    fill_proportional(headers.iter().map(|h| head_w(h)).collect(), available)
1098}
1099
1100/// 表头份额不足可用宽时按比例放大到刚好填满;够宽(会溢出)时原样返回(走横向滚动)。
1101fn fill_proportional(mut base: Vec<f32>, available: f32) -> Vec<f32> {
1102    let sum: f32 = base.iter().sum();
1103    if sum > 0.0 && sum < available {
1104        let k = available / sum;
1105        for w in base.iter_mut() {
1106            *w *= k;
1107        }
1108    }
1109    base
1110}
1111
1112/// 列宽接管版(BD-UI-55 适配层第二入口):内容**逐帧生长**的表(日志正文)由调用方
1113/// 传列当前宽 —— 字体量串走 egui 的 galley 缓存(同串命中),不走库的
1114/// auto_size_this_frame(那条路 = request_discard 每帧双 pass,action 闭包跑两遍)。
1115/// 空态(这一拍没有数据行,由 `n_rows == 0` 现场判定,不用调用方声明)且没传列宽时,
1116/// 按表头比例分配(见 `header_proportional_widths`)。
1117pub(crate) fn pinned_table_impl_cols<'p>(
1118    ui: &mut egui::Ui,
1119    table_id: &str,
1120    headers: &[&str],
1121    sort: Option<(usize, bool)>,
1122    col_current: Option<Vec<f32>>,
1123    add_body: impl FnOnce(&mut PinBody<'p>, f32),
1124) -> Option<usize> {
1125    let ch = Chrome::of(ui.visuals(), crate::tokens::theme_of(ui.ctx()));
1126    let (row_h, head_h) = table_heights(ui);
1127    let sp = ui.spacing().item_spacing;
1128    let m = ui.visuals().clip_rect_margin;
1129    // 表头带:整行宽(Python QHeaderView 铺满表视口;0.2.28 给浮动窗短表补的
1130    // header_band 铺整行是同一件事)。画在表之前 = 在表头文字之下,零布局副作用
1131    // (0.2.6「资金右多一根竖线」那类 min_rect 被撑宽的病在这里不会再犯)。
1132    let left = ui.cursor().min.x - sp.x * 0.5;
1133    let top = ui.cursor().top();
1134    // 带底 = 表头行底边,egui_table 的表头行高就是 `head_h` 且**体表紧接着开始**
1135    // (行与行、表头与体表都不留 item_spacing,table.rs:404-411 `header_row_y` 累加
1136    // `header.height()`)。旧 TableBuilder 在表头与首行之间留了一格 `sp.y`,带底才多画
1137    // 那 6 px —— 照搬过来带底就压进首行(0.4.1 实锤:带 407..442,首行 436..462,
1138    // 首行文字 440..458 被带底横线从中间切过)。
1139    let band = egui::Rect::from_min_max(
1140        egui::pos2(left, top - m),
1141        egui::pos2(left + ui.available_width() + sp.x, top + head_h),
1142    );
1143    ui.painter().rect_filled(band, 0.0, ch.band_fill);
1144    // 带底下边线:表头带与体表的分界(Python 的表头带与体表是两种底色,天然有界;
1145    // 我们只有一根带,补线才有界)。
1146    ui.painter().hline(
1147        band.x_range(),
1148        band.max.y,
1149        egui::Stroke::new(1.0_f32, ch.line),
1150    );
1151    // 滚动条形态:全局一处口径(`tokens::apply` 的 `spacing.scroll`,0.4.1 七期 BD-UI-60
1152    // 起 5 px 细 + BG4 暗把手)。SplitScroll 内部是标准 ScrollArea,吃同一套 spacing。
1153    let mut body = PinBody::default();
1154    add_body(&mut body, row_h);
1155    let n_rows = body.rows.len() as u64;
1156
1157    // 列(0.4.1 五期 BD-UI-58,用户裁定):`resizable` **恒开**,两个理由绑在一起 ——
1158    // ① 列线:库只在 resizable 列上画那根可拖边界线(table.rs:740-749 登记 + 896 画线),
1159    //    这就是用户要的「竖向线显得整齐」;旧口径只有合约表 + 日志表 + 开了右键开关的
1160    //    成交表有列线,委托/持仓/资金/行情没有 → 形制不统一。
1161    // ② 列宽随内容生长:`finish` 的 grow-only 分支(table.rs:840-843)只对 resizable 列
1162    //    生效,旧口径非 resizable 的表列宽只在**首帧**量一次(首帧空态 → 列宽 = 表头
1163    //    文字宽,数据进来也不长;BD-UI-56 实测:成交表首帧空态列宽 [63.3, 28.4, 43.4,
1164    //    …] 喂进「螺纹钢2605 / 71230.5 / 142461.0」再跑三帧,一模一样)。这条缺陷跟着
1165    //    恒开一起修掉。右键菜单里那个「调整列宽」开关因此失去意义 → 一并退役。
1166    let mut delegate = PinDelegate {
1167        headers,
1168        sort,
1169        rows: &mut body.rows,
1170        row_h,
1171        clicked: None,
1172        header_drawn: vec![false; headers.len()],
1173        band_x: band.x_range(),
1174        // 行线吃 **line_soft**(0.8.2,参考站 `td{border-bottom:var(--st-line-soft)}`);
1175        // 表头带那根下边线仍然吃 `line`(参考站 `th{border-bottom:var(--st-line)}`)。
1176        line: ch.line_soft,
1177        band_mid: band.center().y,
1178    };
1179    // egui_table 的表 scope **不带 id_salt**:SplitScroll 内部 `ui.new_child()`
1180    // 的 ScrollArea auto-id 由父 ui 的 child 计数器派生 —— 2×2 各格的表(
1181    // order_grid 与 log_grid)实测滚出同一个 id(17412…5594)—— 滚动状态互相
1182    // 污染 → `ScrollArea::show_scroll` 每帧交替翻转 → request_repaint 死循环
1183    // (kittest max_steps 实锤,本轮 `[scrollflip]` 打点)。所以这里显式
1184    // scope 一层,把每张表的整个子 ui 树根挂在以 table_id 命名的盐上,
1185    // 滚动 id 与列宽状态各自独立。
1186    let _ret = ui.scope_builder(
1187        egui::UiBuilder::new()
1188            .id_salt(("pinned_table", table_id))
1189            // scope 的可用区必须从**当前 cursor** 起,不是从 max_rect 左上角:
1190            // `Table::show` 的 col_x / 表头行 y 都取 `ui.cursor().min`(table.rs:393-411),
1191            // 而上面那条表头带是按 cursor 画的 —— 用 `ui.max_rect()` 会让表画到
1192            // cursor **上方**去,带与表头行错开(0.4.1 实锤 s70:资金表头格 y 568..594,
1193            // 带却画在 y 597..632 = 差一整行)。
1194            .max_rect(egui::Rect::from_min_max(ui.cursor().min, ui.max_rect().max)),
1195        |ui| {
1196            // 列宽校准(BD-UI-58 立,BD-UI-60 补全):表宽 = 所在区域的 100%。
1197            // Python 侧交易表是 `resizeSections(ResizeToContents)`(widget.py:646)+ Qt
1198            // `stretchLastSection` 默认 True(全仓只有 portfoliomanager 的树控件显式关掉它)
1199            // → 列按内容量,剩余宽由**最后一列**吃掉,表宽恒等于视口宽;列总宽超过视口时
1200            // Qt 不压缩、出横向滚动条 —— 我们照这个口径,超宽不压。
1201            // 为什么必须**写进 `TableState`**而不是只传 `Column::new(w)`:库只在状态里
1202            // 没有这张表时才读 `column.current`(table.rs:370-375),而那一刻恰好就是首帧
1203            // ——首帧库给每列打上 `auto_size_this_frame`(table.rs:377-379),`finish` 于是
1204            // 走「按内容收缩」那一支(table.rs:837-839)把种子覆盖掉(0.4.1 五期实测:只传
1205            // 种子,空态右侧照样空 663 px)。先写状态,`TableState::load` 就不再是 None。
1206            // 只在「状态里的列宽总和还填不满可用宽」时动手:用户手动拖宽之后不再跟他抢。
1207            let state_id = egui_table::TableState::id(ui, egui::Id::new(table_id));
1208            // 目标宽要扣掉这张表自己的滚动条占位(`floating = false` 时 ScrollArea 会真的
1209            // 留出 bar_width + 内外边距):不扣就会把列总宽做到「比可视区还宽」,最后一列
1210            // 被推到视口外,egui_table 剔掉视口外的列 → 那一列上的撤单钮 / 时间格直接消失
1211            // (0.4.1 七期实撞:s11 撤单钮、s38 已摘除标注、w6 成交时间列同因)。
1212            let sc = &ui.style().spacing.scroll;
1213            let reserve = if sc.floating {
1214                0.0
1215            } else {
1216                sc.bar_width + sc.bar_inner_margin + sc.bar_outer_margin
1217            };
1218            let target = (ui.available_width() - reserve).max(0.0);
1219            // 补宽**一次只补一次**,不每帧补:库的 `finish` 把列宽写回成 `max(可视内容宽)`,
1220            // 被剔出视口的列写回的是它这一帧的量测宽 → 每帧补就「补宽 → 更挤 → 更多列被剔 →
1221            // 存宽更小 → 再补」滚起来(0.4.1 七期实撞:每帧补时 s11/s21/t3/f2/w6 同时丢格)。
1222            // 判据 = 「区域宽变了」或「存宽变了」(= 数据变了)才重新允许补一次;最后一列最多
1223            // 吃掉半张表,补不出无限宽的列。
1224            let fill_id = egui::Id::new(("table_fill", table_id));
1225            let (last_target, last_stored, filled) = ui.ctx().data(|d| {
1226                (
1227                    d.get_temp::<f32>(fill_id).unwrap_or(0.0),
1228                    d.get_temp::<f32>(fill_id.with("stored")).unwrap_or(0.0),
1229                    d.get_temp::<bool>(fill_id.with("filled")).unwrap_or(false),
1230                )
1231            });
1232            let mut st = egui_table::TableState::load(ui.ctx(), state_id).unwrap_or_default();
1233            let stored: f32 = st.col_widths.values().sum();
1234            let may_fill = !filled
1235                && ((target - last_target).abs() > 8.0 || (stored - last_stored).abs() > 0.5);
1236            let mut did_fill = false;
1237            let mut new_stored = stored;
1238            if col_current.is_none() && may_fill && stored < target {
1239                if stored <= 0.0 {
1240                    // 这一张表还没量过列宽。**只有空态**才在这里按表头比例下种子(BD-UI-58
1241                    // 口径);有数据时不写,让库在首帧做全量内容量测(`do_full_sizing_pass`)
1242                    // —— 提前下种子会把「时间」「接口」这类窄表头列压成 26 px,格内文字被
1243                    // 列宽裁掉(0.4.1 七期实撞:w6 成交时间列、s38 已摘除标注)。
1244                    if n_rows == 0 {
1245                        for (i, w) in header_proportional_widths(ui, headers, target)
1246                            .into_iter()
1247                            .enumerate()
1248                        {
1249                            st.col_widths.insert(egui::Id::new(i), w);
1250                        }
1251                        did_fill = true;
1252                    }
1253                } else {
1254                    // 已有列宽(按内容量过):剩余补给最后一列 = stretchLastSection 等价。
1255                    let last = egui::Id::new(headers.len() - 1);
1256                    let cur = st.col_widths.get(&last).copied().unwrap_or(24.0);
1257                    let add = (target - stored).min((target * 0.5 - cur).max(0.0));
1258                    if add > 0.5 {
1259                        st.col_widths.insert(last, cur + add);
1260                        did_fill = true;
1261                    }
1262                }
1263                if did_fill {
1264                    new_stored = st.col_widths.values().sum::<f32>();
1265                    st.store(ui.ctx(), state_id);
1266                }
1267            }
1268            ui.ctx().data_mut(|d| {
1269                d.insert_temp(fill_id, target);
1270                d.insert_temp(fill_id.with("stored"), new_stored);
1271                d.insert_temp(fill_id.with("filled"), did_fill || filled);
1272            });
1273            let seed: Option<Vec<f32>> = col_current;
1274            // 列线(0.4.1 七期 BD-UI-60,用户裁定「表格里所有的列分割线都去掉,只要行的分割线」):
1275            // 库里那根线撤不掉 —— 它只在**可拖列边界**上画(table.rs:738-749 登记 + 896 画线),
1276            // 而 `resizable` 必须恒开(列宽随内容生长靠它,BD-UI-56 那条缺陷就堵在这)。
1277            // 做法 = 把它的**常态**描边改成全透明 → 平时看不见;hover / 拖拽仍走
1278            // `widgets.hovered` / `widgets.active` 的描边 → 拖列宽时那根线亮出来(用户裁定保留)。
1279            // 只改这一支,不改 `BORDER` 本体:行线、表头带底边线、窗框都另有取色(行线取
1280            // delegate 里捕获的 `line`)。
1281            ui.style_mut().visuals.widgets.noninteractive.bg_stroke =
1282                egui::Stroke::new(1.0_f32, egui::Color32::TRANSPARENT);
1283            // 列宽从哪来(0.4.1 实查,库侧两条硬事实):
1284            // ① `Table::show` 只在**首帧**(`TableState::load` 为 None)做全量内容量测
1285            //    (`do_full_sizing_pass`,table.rs:365-379);
1286            // ② 量出来的宽度只有 **resizable 列**才会写回 `TableState` —— `finish`
1287            //    (table.rs:816-843)只遍历 `visible_column_lines`,而这两根线只在
1288            //    `column.resizable` 时登记(table.rs:614、table.rs:738-749)。
1289            // 0.4.1 五期(BD-UI-58)起 `resizable` 恒开:①②两条从此**每张表都吃到**
1290            // (列宽随内容生长 + 每列一根可拖列线),不再需要「首帧临时全标 resizable」
1291            // 那套补丁。起手宽 = 空态种子(按表头比例)或 100(库随后按内容量测覆盖)。
1292            let columns: Vec<egui_table::Column> = match &seed {
1293                Some(ws) => headers
1294                    .iter()
1295                    .zip(ws.iter().copied())
1296                    .enumerate()
1297                    .map(|(i, (_, w))| {
1298                        egui_table::Column::new(w)
1299                            .range(24.0..=f32::INFINITY)
1300                            .id(egui::Id::new(i))
1301                            .resizable(true)
1302                    })
1303                    .collect(),
1304                None => headers
1305                    .iter()
1306                    .enumerate()
1307                    .map(|(i, _)| {
1308                        egui_table::Column::new(100.0)
1309                            .range(24.0..=f32::INFINITY)
1310                            .id(egui::Id::new(i))
1311                            .resizable(true)
1312                    })
1313                    .collect(),
1314            };
1315            egui_table::Table::new()
1316                .id_salt(table_id)
1317                .num_rows(n_rows)
1318                .columns(columns)
1319                .headers([egui_table::HeaderRow::new(head_h)])
1320                .auto_size_mode(egui_table::AutoSizeMode::Never)
1321                .show(ui, &mut delegate);
1322        },
1323    );
1324    delegate.clicked
1325}
1326/// 为什么要这个:egui 的点击只发给**一个**响应 —— `context.rs:1384`
1327/// `let clicked = Some(id) == viewport.interact_widgets.clicked;` 比的是单个 Id,
1328/// 后加的控件优先。格里有真控件(钮 / 勾选框)时,点击归那个控件,归不到
1329/// `TableRow::col` 返回的「格响应」。所以行动作必须两边都看(0.2.29 实锤:
1330/// 只看格响应 → 行情首列钮、委托撤单钮、合约勾选框三处点击全丢)。
1331/// `pin_cell` 是 pub(crate)(kittest 判据要用),所以本 trait 也要 pub(crate)
1332/// (否则 clippy private-trait 警报)。
1333pub(crate) trait CellOutcome {
1334    fn response(&self) -> Option<egui::Response>;
1335}
1336
1337impl CellOutcome for egui::Response {
1338    fn response(&self) -> Option<egui::Response> {
1339        Some(self.clone())
1340    }
1341}
1342
1343impl CellOutcome for () {
1344    fn response(&self) -> Option<egui::Response> {
1345        None
1346    }
1347}
1348
1349impl<T: CellOutcome> CellOutcome for egui::InnerResponse<T> {
1350    fn response(&self) -> Option<egui::Response> {
1351        self.inner
1352            .response()
1353            .or_else(|| Some(self.response.clone()))
1354    }
1355}
1356
1357/// 喂表前的快照副本排序(W2 的判据对象)。`key` 返回**可比较字符串**;数字列调用方给
1358/// 零填充串(`{:020.4}`)保证字典序 == 数值序。`sort` 为 `None` = 原序,一行都不动。
1359fn sorted_rows<'a, T>(
1360    rows: &'a [T],
1361    sort: Option<(usize, bool)>,
1362    key: impl Fn(&T, usize) -> String,
1363) -> Vec<&'a T> {
1364    let mut out: Vec<&'a T> = rows.iter().collect();
1365    if let Some((col, asc)) = sort {
1366        out.sort_by(|a, b| {
1367            let (ka, kb) = (key(a, col), key(b, col));
1368            if asc { ka.cmp(&kb) } else { kb.cmp(&ka) }
1369        });
1370    }
1371    out
1372}
1373
1374/// 成交行的展示字符串(`TRADE_HEADERS` 同序)。**排序键与「保存数据」CSV 共用这一份口径** ——
1375/// 表里看见什么,导出来就是什么,不留第二套格式化。
1376/// 0.3.3 Wave F:这一份的顺序就是 `TRADE_HEADERS` 的顺序(判据 F1 钉「内容与表头同列」,
1377/// 两处不同序 = CSV 与屏幕不一致)。
1378fn trade_cells(t: &ek_data::trade::TradeData) -> Vec<String> {
1379    vec![
1380        t.symbol.clone(),
1381        if t.name.is_empty() {
1382            // 0.11.0 第5刀 R6:导出与屏幕同口径 —— 缺名兜底 = 代码
1383            t.symbol.clone()
1384        } else {
1385            t.name.clone()
1386        },
1387        t.exchange.as_str().to_string(),
1388        t.direction
1389            .map(|d| d.as_str().to_string())
1390            .unwrap_or_else(|| "-".to_string()),
1391        t.offset.as_str().to_string(),
1392        crate::format::price(t.price),
1393        crate::format::volume(t.volume),
1394        crate::format::price(t.traded_amount),
1395        crate::format::time_hms_millis(t.datetime),
1396        t.tradeid.clone(),
1397        t.orderid.clone(),
1398        t.gateway_name.clone(),
1399    ]
1400}
1401
1402/// 排序键:数字列给**零填充串**(`{:020.4}`)保证字典序 == 数值序;文本列直接比。
1403/// 注意不能直接拿 `trade_cells` 的显示串当排序键 —— "101" < "12.345" < "3.5" 是字典序,
1404/// 不是价格序(本轮实撞,判据 W1 当场红出来的)。
1405/// 列序 0.3.3 Wave F 跟着 `TRADE_HEADERS` 走:5 = 价格,6 = 数量,7 = 成交金额,8 = 时间。
1406fn trade_sort_key(t: &ek_data::trade::TradeData, col: usize) -> String {
1407    match col {
1408        5 => format!("{:020.4}", t.price),
1409        6 => format!("{:020.4}", t.volume),
1410        7 => format!("{:020.4}", t.traded_amount),
1411        8 => t.datetime.map(|ns| format!("{ns:025}")).unwrap_or_default(),
1412        c => trade_cells(t).get(c).cloned().unwrap_or_default(),
1413    }
1414}
1415
1416/// 「保存数据」:当前表(已按界面排序键排好的那份副本)写成 CSV 到 `target/ek-ui-<表>.csv`。
1417/// 只写界面看见的内容,不碰后端、不碰数据模型。
1418fn save_table_csv<T>(
1419    table: &str,
1420    headers: &[&str],
1421    rows: &[&T],
1422    cells: impl Fn(&T) -> Vec<String>,
1423) -> std::io::Result<()> {
1424    let mut s = headers.join(",");
1425    s.push('\n');
1426    for r in rows {
1427        s.push_str(&cells(r).join(","));
1428        s.push('\n');
1429    }
1430    let dir = std::env::current_dir()?.join("target");
1431    std::fs::create_dir_all(&dir)?;
1432    std::fs::write(dir.join(format!("ek-ui-{table}.csv")), s)
1433}
1434
1435// ================= 菜单栏 =================
1436
1437fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
1438    ui.horizontal(|ui| {
1439        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
1440        if ui.button(MENU_OPS).clicked() {
1441            shared.set_ops_window_open(true);
1442        }
1443        if ui.button(MENU_ACCOUNTS).clicked() {
1444            shared.set_accounts_window_open(true);
1445        }
1446        // 0.9.10:「参数面/档案包」两钮连功能铲去(用户裁定);主菜单 = 运维/账户/查询合约/复位。
1447        if ui.button(MENU_CONTRACTS).clicked() {
1448            shared.set_contracts_window_open(true);
1449        }
1450        // 0.3.0(Wave C,BD-UI-48)→ 0.4.1(BD-UI-54):布局可复位 —— 一键回
1451        // Python 框架等价(分刀份额 50:50 + 三个 tab 态回缺省;行情带/下单侧两块
1452        // 顶层面板的记忆尺寸一并清掉 = 下帧回 default_height/default_width)。
1453        if ui.button("恢复布局").clicked() {
1454            shared.reset_layout();
1455            ui.ctx().data_mut(|d| {
1456                d.remove::<egui::containers::panel::PanelState>(egui::Id::new("market_band"));
1457                d.remove::<egui::containers::panel::PanelState>(egui::Id::new("order_side"));
1458            });
1459        }
1460        // **0.8.0 配色双档(BD-UI-61):两档切换钮 —— 裁定 8 的改判点。**
1461        // 0.2.30(裁定 8,BD-UI-46)删掉的是「五套主题一键循环」;0.4.1(BD-UI-57)
1462        // 把那一套定成 Nova 深色。本轮用户实看判词「这个配色特别刺眼睛」+ 指定参考站
1463        // 要「白色和黑色两款」→ 深色档重调 + 新增浅色档 + 菜单放**一颗**两档钮。
1464        // 标签 = **要去的那一档**(参考站「图标指向目标档」的语义,我们用中文,不引图标字体)。
1465        // 入口只有这一颗(参考站那两处是它的长设置页,我们的设置面就是菜单这一行)。
1466        let theme = shared.theme();
1467        let to = theme.flipped();
1468        if ui
1469            .button(format!(
1470                "主题:{}",
1471                match to {
1472                    crate::tokens::Theme::Light => "浅色",
1473                    crate::tokens::Theme::Dark => "深色",
1474                }
1475            ))
1476            .clicked()
1477        {
1478            let next = shared.toggle_theme();
1479            ui.ctx().request_repaint();
1480            shared.log_line(format!(
1481                "[ui] 主题:{}(下一帧生效,已记 ui.theme)",
1482                match next {
1483                    crate::tokens::Theme::Light => "浅色",
1484                    crate::tokens::Theme::Dark => "深色",
1485                }
1486            ));
1487        }
1488        ui.menu_button(MENU_HELP, |ui| {
1489            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
1490            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
1491            ui.label("配色 = 深/浅两档(缺省深色),菜单「主题:」钮切换,存 ui.theme");
1492        });
1493    });
1494}
1495
1496// ================= 行情格 =================
1497
1498/// 行情表头(16 列,与 `tick_sort_key` 同序;成交额/持仓量 2026-10-28 按对照
1499/// python 期货指数行情窗补列 —— `TickData.turnover/open_interest` 已有,纯显示层)。
1500const TICK_HEADERS: [&str; 16] = [
1501    "代码",
1502    "名称",
1503    "最新价",
1504    "涨跌幅",
1505    "成交量",
1506    "成交额",
1507    "持仓量",
1508    "今开",
1509    "最高",
1510    "最低",
1511    "买1价",
1512    "买1量",
1513    "卖1价",
1514    "卖1量",
1515    "时间",
1516    "行情源",
1517];
1518
1519/// 行情格:一个交易所一格(0.4.0)。组内那条「按所二级 tab」条已撤除 —— 所的身份由
1520/// dock tab 承担(该格只画本所的行;哪一所在前台由 dock 树记)。
1521pub(crate) fn render_market_pane(
1522    ui: &mut egui::Ui,
1523    shared: &UiShared,
1524    ex: ek_data::constant::Exchange,
1525) {
1526    // 表状态按格分开:6 个所的行情格各有各的排序与列宽(同一个 id 会被 egui 当重复)。
1527    let table_id = format!("tick_{}", ex.as_str());
1528    let groups = shared.tick_groups();
1529    let ticks: Vec<ek_data::tick::TickData> = groups
1530        .iter()
1531        .find(|(e, _)| e == ex.as_str())
1532        .map(|(_, v)| v.clone())
1533        .unwrap_or_default();
1534    // 空态口径(0.4.1 五期 BD-UI-58 + 六期 BD-UI-59 两次裁定合起来):表头恒在(s73 钉),但**不画那一行
1535    // 「(无数据)」提示** —— 表体就是空的,用户看得见;列宽这一拍按表头比例填满(s74 钉)。
1536    // 「这一拍没有数据行」由 `pinned_table_impl_cols` 现场数 `n_rows == 0` 判定,
1537    // 调用方不再分支:空与非空走同一条路,body 收出 0 行就是空表。
1538    // (0.4.0 那条「空态不得吞掉表头」的修正仍然成立:这里原来直接 `muted_label + return`,
1539    // 整张表含表头都不画,六个行情格在没有订阅时全是光秃秃一句提示。Python 也是空表照画表头。)
1540    // 0.3.3(收 Wave D 欠账):行情格也吃「点表头排序」(缺省序 = 按所/symbol)。
1541    let sort = shared.table_sort(&table_id);
1542    let ticks = sorted_rows(&ticks, sort, tick_sort_key);
1543    let clicked = pinned_table_sorted(ui, &table_id, &TICK_HEADERS, sort, |body, row_h| {
1544        for t in &ticks {
1545            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
1546            // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
1547            // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
1548            // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
1549            let row_sel = shared.order_form().symbol == t.symbol;
1550            body.row(row_h, row_sel, |r| {
1551                // 延迟画笔(BD-UI-55):格内容在体表真实绘制时跑;点击/双击与
1552                // 行动作的判定照旧在「格响应 || 控件响应」两边看,BD-UI-55 实查:
1553                // egui_table 每帧只跑一遍(不用 auto_size_this_frame,那会双跑)。
1554                // 闭包不 move(借用 shared / t / key —— 它们都是外层的只读局部,
1555                // 出不了 pinned_table_impl 的调用帧,借给 'b 完全合法)。
1556                macro_rules! cell {
1557                    ($make:expr, $col:expr) => {{
1558                        r.cell(|ui, ctx| {
1559                            let (cell_r, inner_r) = pin_cell(ui, ctx, $make);
1560                            let hit =
1561                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
1562                            if hit {
1563                                // import_tick(用户裁定:0.4.1 行单击不再切交易所 ——
1564                                // 本格已是该所;这里只把合约带进下单面板)。
1565                                import_tick(shared, t, ex.as_str(), $col);
1566                            }
1567                            let dbl = cell_r.double_clicked()
1568                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
1569                            if dbl {
1570                                shared.open_kline(t.symbol.clone());
1571                            }
1572                        });
1573                    }};
1574                }
1575                cell!(|ui| ui.button(t.symbol.clone()), 0);
1576                cell!(
1577                    |ui| ui.label(if t.name.is_empty() {
1578                        // 0.11.0 第5刀 R6:名称兜底 = 代码(不是「-」;行永不因缺名被丢)
1579                        t.symbol.clone()
1580                    } else {
1581                        t.name.clone()
1582                    }),
1583                    1
1584                );
1585                cell!(
1586                    |ui| ui.label(crate::tokens::num_text(crate::format::price(t.last_price))),
1587                    2
1588                );
1589                cell!(
1590                    |ui| ui.label(
1591                        crate::tokens::num_text(crate::format::pct(t.pct_change))
1592                            .color(crate::tokens::rise_fall(ui.ctx(), t.pct_change)),
1593                    ),
1594                    3
1595                );
1596                cell!(
1597                    |ui| ui.label(crate::tokens::num_text(crate::format::volume(t.volume))),
1598                    4
1599                );
1600                cell!(
1601                    |ui| ui.label(crate::tokens::num_text(crate::format::price(t.turnover))),
1602                    5
1603                );
1604                cell!(
1605                    |ui| ui.label(crate::tokens::num_text(crate::format::volume(
1606                        t.open_interest
1607                    ))),
1608                    6
1609                );
1610                cell!(
1611                    |ui| ui.label(crate::tokens::num_text(crate::format::price(t.open_price))),
1612                    7
1613                );
1614                cell!(
1615                    |ui| ui.label(crate::tokens::num_text(crate::format::price(t.high_price))),
1616                    8
1617                );
1618                cell!(
1619                    |ui| ui.label(crate::tokens::num_text(crate::format::price(t.low_price))),
1620                    9
1621                );
1622                cell!(
1623                    |ui| ui.label(
1624                        crate::tokens::num_text(crate::format::price(t.bid_price_1))
1625                            .color(crate::tokens::rise(ui.ctx())),
1626                    ),
1627                    10
1628                );
1629                cell!(
1630                    |ui| ui.label(
1631                        crate::tokens::num_text(crate::format::volume(t.bid_volume_1))
1632                            .color(crate::tokens::rise(ui.ctx())),
1633                    ),
1634                    11
1635                );
1636                cell!(
1637                    |ui| ui.label(
1638                        crate::tokens::num_text(crate::format::price(t.ask_price_1))
1639                            .color(crate::tokens::fall(ui.ctx())),
1640                    ),
1641                    12
1642                );
1643                cell!(
1644                    |ui| ui.label(
1645                        egui::RichText::new(crate::format::volume(t.ask_volume_1))
1646                            .color(crate::tokens::fall(ui.ctx())),
1647                    ),
1648                    13
1649                );
1650                cell!(
1651                    |ui| ui.label(if t.update_time.is_empty() {
1652                        crate::format::time_hms_millis(Some(t.datetime))
1653                    } else {
1654                        t.update_time.clone()
1655                    }),
1656                    14
1657                );
1658                cell!(|ui| ui.label(t.gateway_name.clone()), 15);
1659            });
1660        }
1661    });
1662    if let Some(col) = clicked {
1663        shared.toggle_table_sort(&table_id, col);
1664    }
1665}
1666
1667/// 行情行的排序键(列序 == 行情带 16 列):数字列零填充,时间列纳秒零填充。
1668fn tick_sort_key(t: &ek_data::tick::TickData, col: usize) -> String {
1669    match col {
1670        0 => t.symbol.clone(),
1671        1 => t.name.clone(),
1672        2 => format!("{:020.4}", t.last_price),
1673        3 => format!("{:020.4}", t.pct_change),
1674        4 => format!("{:020.4}", t.volume),
1675        5 => format!("{:020.4}", t.turnover),
1676        6 => format!("{:020.4}", t.open_interest),
1677        7 => format!("{:020.4}", t.open_price),
1678        8 => format!("{:020.4}", t.high_price),
1679        9 => format!("{:020.4}", t.low_price),
1680        10 => format!("{:020.4}", t.bid_price_1),
1681        11 => format!("{:020.4}", t.bid_volume_1),
1682        12 => format!("{:020.4}", t.ask_price_1),
1683        13 => format!("{:020.4}", t.ask_volume_1),
1684        14 => {
1685            if t.update_time.is_empty() {
1686                format!("{:025}", t.datetime)
1687            } else {
1688                t.update_time.clone()
1689            }
1690        }
1691        15 => t.gateway_name.clone(),
1692        _ => String::new(),
1693    }
1694}
1695
1696// ================= 下单面板(常驻;0.4.1 BD-UI-54 回原生 SidePanel) =================
1697
1698fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
1699    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
1700    let mut f = shared.order_form();
1701    let before = f.clone();
1702    let mut err: Option<String> = None;
1703
1704    // 随价勾选:价格框随该合约最新 tick(勾选态)
1705    if f.follow_price
1706        && let Some(t) = tick_of(shared, &f.exchange, &f.symbol)
1707        && t.last_price > 0.0
1708    {
1709        f.price = crate::format::price(t.last_price);
1710    }
1711
1712    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
1713    let mut ex_opts: Vec<String> = shared
1714        .tick_groups()
1715        .iter()
1716        .map(|(e, _)| e.clone())
1717        .collect();
1718    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
1719        if !ex_opts.iter().any(|x| x == e) {
1720            ex_opts.push(e.to_string());
1721        }
1722    }
1723    if !ex_opts.contains(&f.exchange) {
1724        ex_opts.insert(0, f.exchange.clone());
1725    }
1726    // 接口下拉:账户清册(注册序)∪ trade_snap 网关去重集(B3 退役:
1727    // 不再硬编码 `["ctp"]`;0.2.10 BD-UI-38 sim 纸面网关退场同口径)。
1728    let ts = shared.trade_snap();
1729    let mut gw_opts: Vec<String> = Vec::new();
1730    // 0.2.18(规划 Q4):下单归属只列**已装配**账户 —— 只添加了还没连接的账户
1731    // 进了下拉,选它下单只会撞 `unknown:gateway`。
1732    // 账本侧历史网关名的口径(执行核查补的洞):清册非空时,**不在清册的历史网关
1733    // = 已摘除账户**,它留在下拉里同样是选它下单撞 `unknown:gateway` → 不进下拉
1734    // (撤单不受影响:撤单归属按每笔单自己的网关走,与这里的下拉无关)。
1735    // 清册为空 = 不干预(纯账本巡回钉 s27/s14 那类无账户配置面的形)。
1736    let known_gws = account_gateways(shared);
1737    for g in shared
1738        .accounts()
1739        .iter()
1740        .filter(|a| a.connected)
1741        .map(|a| a.gateway.clone())
1742        .chain(
1743            ts.orders
1744                .iter()
1745                .map(|o| o.gateway_name.clone())
1746                .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
1747                .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
1748                .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
1749        )
1750    {
1751        if !g.is_empty() && !gw_opts.contains(&g) {
1752            gw_opts.push(g);
1753        }
1754    }
1755    if !gw_opts.contains(&f.gateway)
1756        && !f.gateway.is_empty()
1757        && (known_gws.is_empty() || known_gws.contains(&f.gateway))
1758    {
1759        gw_opts.insert(0, f.gateway.clone());
1760    }
1761    // 手动选过的网关若已不在可选面(被摘除 / 未装配)= 就地回落到默认阶梯:
1762    // 否则下拉里挂着一个注定 `unknown:gateway` 的选项,且它会成为下一笔单的归属。
1763    if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
1764        f.gateway = shared.default_gateway().unwrap_or_default();
1765    }
1766    // 表单网关位为空 = 未手动选过:回填默认账户阶梯(上次选择 → 首个已连接 → 首个)。
1767    // 只改本地 `f` 副本 —— 面板末尾的 `f != before` 写回负责落盘,不在这里重复写。
1768    if f.gateway.is_empty()
1769        && let Some(def) = shared.default_gateway()
1770    {
1771        f.gateway = def;
1772    }
1773    let no_accounts = gw_opts.is_empty();
1774
1775    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
1776    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
1777    egui::Grid::new("order_form_grid")
1778        .num_columns(2)
1779        .show(ui, |ui| {
1780            ui.label("交易所");
1781            egui::ComboBox::from_id_salt("of_exchange")
1782                .selected_text(f.exchange.clone())
1783                .show_ui(ui, |ui| {
1784                    for e in &ex_opts {
1785                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
1786                    }
1787                });
1788            ui.end_row();
1789            ui.label("代码");
1790            let sym_resp = ui.add(
1791                egui::TextEdit::singleline(&mut f.symbol)
1792                    .hint_text("cu2709")
1793                    .desired_width(150.0),
1794            );
1795            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
1796            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
1797            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
1798            // has_focus 口径回车订阅从不触发)。
1799            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
1800                let s = f.symbol.trim().to_string();
1801                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
1802                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
1803                // 归一化交给网关侧实现(`ek_ctp::map::instrument_id`):界面前后两份
1804                // "剥 EX. 前缀"就是重复;手剥还会把 `XX.ag2612` 这种未知前缀悄悄剥成合法码。
1805                let bare = ek_ctp::map::instrument_id(&s).unwrap_or_default();
1806                if bare.is_empty() && !s.is_empty() {
1807                    shared.log_line(format!("[订阅] 合约代码无法归一(交易所前缀未知):{s}"));
1808                }
1809                if !bare.is_empty() {
1810                    f.symbol = bare.clone();
1811                    // 订阅一律路由到行情元账号(规划 §5.C:单源;从账号无 MD 会话,
1812                    // 发给它只会静默成功 = 观测黑洞,所以路由在界面侧定死)。
1813                    match subscribe_target(shared) {
1814                        Ok(gw) => {
1815                            shared.push_cmd(UiCmd::CtpSubscribe {
1816                                gateway: gw.clone(),
1817                                symbols: vec![bare.clone()],
1818                            });
1819                            shared
1820                                .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
1821                        }
1822                        Err(e) => shared.log_line(format!("[订阅] {e}")),
1823                    }
1824                }
1825            }
1826            ui.end_row();
1827            ui.label("名称");
1828            ui.label(
1829                tick_of(shared, &f.exchange, &f.symbol)
1830                    .map(|t| {
1831                        if t.name.is_empty() {
1832                            // 0.11.0 第5刀 R6:缺名兜底 = 代码
1833                            t.symbol.clone()
1834                        } else {
1835                            t.name
1836                        }
1837                    })
1838                    .unwrap_or_else(|| "无行情".to_string()),
1839            );
1840            ui.end_row();
1841            ui.label("方向");
1842            egui::ComboBox::from_id_salt("of_direction")
1843                .selected_text(f.direction.as_str())
1844                .show_ui(ui, |ui| {
1845                    for d in [Direction::LONG, Direction::SHORT] {
1846                        ui.selectable_value(&mut f.direction, d, d.as_str());
1847                    }
1848                });
1849            ui.end_row();
1850            ui.label("开平");
1851            ui.horizontal(|ui| {
1852                egui::ComboBox::from_id_salt("of_offset")
1853                    .selected_text(f.offset.as_str())
1854                    .show_ui(ui, |ui| {
1855                        for o in [
1856                            Offset::OPEN,
1857                            Offset::CLOSE,
1858                            Offset::CLOSETODAY,
1859                            Offset::CLOSEYESTERDAY,
1860                        ] {
1861                            ui.selectable_value(&mut f.offset, o, o.as_str());
1862                        }
1863                    });
1864                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
1865                ui.checkbox(&mut f.auto_split, "自动开平");
1866            });
1867            ui.end_row();
1868            ui.label("类型");
1869            egui::ComboBox::from_id_salt("of_order_type")
1870                .selected_text(f.order_type.as_str())
1871                .show_ui(ui, |ui| {
1872                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
1873                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
1874                    }
1875                });
1876            ui.end_row();
1877            ui.label("价格");
1878            ui.horizontal(|ui| {
1879                ui.add(
1880                    egui::TextEdit::singleline(&mut f.price)
1881                        .hint_text("107660")
1882                        .desired_width(90.0),
1883                );
1884                ui.checkbox(&mut f.follow_price, "随价");
1885            });
1886            ui.end_row();
1887            ui.label("数量");
1888            ui.add(
1889                egui::TextEdit::singleline(&mut f.volume)
1890                    .hint_text("1")
1891                    .desired_width(90.0),
1892            );
1893            ui.end_row();
1894            ui.label("接口");
1895            egui::ComboBox::from_id_salt("of_gateway")
1896                .selected_text(if f.gateway.is_empty() {
1897                    "(未选账户)".to_string()
1898                } else {
1899                    f.gateway.clone()
1900                })
1901                .show_ui(ui, |ui| {
1902                    for g in &gw_opts {
1903                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
1904                    }
1905                });
1906            ui.end_row();
1907            ui.label("操作");
1908            ui.horizontal(|ui| {
1909                // 无账户 = 无网关可下单(旧「空 → 字面量 ctp」兜底已退役,B4)。
1910                let send = ui
1911                    .add_enabled_ui(!no_accounts, |ui| {
1912                        crate::ui_ext::primary_button(ui, BTN_SEND_ORDER)
1913                    })
1914                    .inner
1915                    .clicked();
1916                if send {
1917                    match f.to_request() {
1918                        Ok(req) => {
1919                            let fallback = shared.default_gateway();
1920                            match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
1921                                Ok(gateway) => {
1922                                    shared.push_cmd(UiCmd::SendOrder {
1923                                        req,
1924                                        gateway,
1925                                        mode: if f.auto_split {
1926                                            ek_oms::LockMode::Auto
1927                                        } else {
1928                                            ek_oms::LockMode::None
1929                                        },
1930                                    });
1931                                    shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
1932                                }
1933                                Err(e) => {
1934                                    shared.log_line(format!("[下单] {e}"));
1935                                    err = Some(e);
1936                                }
1937                            }
1938                        }
1939                        Err(e) => {
1940                            shared.log_line(format!("[下单] {e}"));
1941                            err = Some(e);
1942                        }
1943                    }
1944                }
1945                if ui.button(BTN_CANCEL_ALL).clicked() {
1946                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
1947                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
1948                    let mut n = 0usize;
1949                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
1950                        // 撤单跟着**这笔单自己的网关**走(多账号面:不再回落 ctp)。
1951                        let gw = if !o.gateway_name.is_empty() {
1952                            o.gateway_name.clone()
1953                        } else {
1954                            shared.default_gateway().unwrap_or_default()
1955                        };
1956                        if gw.is_empty() {
1957                            continue;
1958                        }
1959                        shared.push_cmd(UiCmd::CancelOrder {
1960                            orderid: o.orderid.clone(),
1961                            gateway: gw,
1962                        });
1963                        n += 1;
1964                    }
1965                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
1966                }
1967            });
1968            ui.end_row();
1969            crate::ui_ext::muted_label(ui, "代码回车 = 订阅");
1970            ui.label("");
1971            ui.end_row();
1972        });
1973    if let Some(e) = err {
1974        ui.label(egui::RichText::new(e).color(crate::tokens::error(ui.ctx())));
1975    }
1976    if no_accounts {
1977        // 侧栏底部独立行(不落钮行内:会把「全撤」推出侧栏宽域,s3 结构钉)。
1978        ui.label(
1979            egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
1980                .color(crate::tokens::warn(ui.ctx())),
1981        );
1982    }
1983    if f != before {
1984        shared.set_order_form(f.clone());
1985    }
1986}
1987
1988/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
1989/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
1990/// 行情带跳色行指认,盘口帧不重复报幕)。
1991/// 盘口买 / 卖侧色(0.8.0:走当前档的访问器,浅档才会跟档 —— 旧口径是 `const`,
1992/// const 拿不到 `Context`,直用深档常量 = 浅色档下这两支不跟档)。
1993/// 卖 = 跌绿,买 = 涨红(hue 恒定,明度跟档,判据 `u11`)。
1994fn book_ask_color(ctx: &egui::Context) -> egui::Color32 {
1995    crate::tokens::fall(ctx)
1996}
1997fn book_bid_color(ctx: &egui::Context) -> egui::Color32 {
1998    crate::tokens::rise(ctx)
1999}
2000
2001/// 盘口一行 = 三格:档位标签 / 价 / 量(BD-UI-55:egui_extras 退役,盘口换
2002/// `egui::Grid` 三列 —— 它只是一块自适应小表,没有表头/粘顶/角色,够不上
2003/// egui_table 的 delegate 拉动化)。
2004/// **0.8.0 对齐统一(裁定 D):价与量不再右对齐** —— 与档位标签同一左沿,数字走
2005/// `tokens::num_text`(等宽)。这一块的数字仍成列,靠的是等宽不是贴右(毛病 6
2006/// 「盘口贴左缘」由三列小表解决,右对齐退役后由等宽接着守)。
2007fn book_row(row: &mut egui::Ui, tag: String, color: egui::Color32, price: f64, volume: f64) {
2008    row.label(egui::RichText::new(tag).color(color));
2009    row.label(crate::tokens::num_text(crate::format::price(price)).color(color));
2010    row.label(crate::tokens::num_text(crate::format::volume(volume)).color(color));
2011}
2012
2013fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
2014    let f = shared.order_form();
2015    let Some(t) = tick_of(shared, &f.exchange, &f.symbol) else {
2016        crate::ui_ext::muted_label(ui, "(无 tick)");
2017        return;
2018    };
2019    // 0.2.29 Wave A 尾巴(BD-UI-45,毛病 6 的本体):五档盘口原来是一支文本
2020    // `ui.label("卖5  101.5  3")` —— 价与量靠**空格**对齐,位数一变(101.5 → 12345.5)
2021    // 整块就错位、贴左缘。换成三列小表(档 / 价 / 量),价与量右对齐成列。
2022    // 这里**不**用 `pinned_table`:下单侧是窄列 + 外层已有 ScrollArea,`pinned_table`
2023    // 的 `auto_shrink([false, false])` 会吃满整块可用高(0.2.17 s39 那条教训同型)。
2024    // 表头也不加:Python 侧没有五档盘口这个块(只有行情表的买1价/买1量/卖1价/卖1量
2025    // 四列),这是我们自加的常驻盘口,保持无表头。
2026    let (_, _head_h) = table_heights(ui);
2027    let asks: [(f64, f64); 5] = [
2028        (t.ask_price_5, t.ask_volume_5),
2029        (t.ask_price_4, t.ask_volume_4),
2030        (t.ask_price_3, t.ask_volume_3),
2031        (t.ask_price_2, t.ask_volume_2),
2032        (t.ask_price_1, t.ask_volume_1),
2033    ];
2034    let bids: [(f64, f64); 5] = [
2035        (t.bid_price_1, t.bid_volume_1),
2036        (t.bid_price_2, t.bid_volume_2),
2037        (t.bid_price_3, t.bid_volume_3),
2038        (t.bid_price_4, t.bid_volume_4),
2039        (t.bid_price_5, t.bid_volume_5),
2040    ];
2041    // 取色**提到 Grid 之前**(0.8.0):`book_ask_color` 要读 `ui.ctx()`,在 `show` 的闭包里
2042    // 读会和 `grid` 的可变借用撞(E0502);一帧里档不会变,取两次就够。
2043    let ask_color = book_ask_color(ui.ctx());
2044    let bid_color = book_bid_color(ui.ctx());
2045    // `num_columns` 会让**最后一列吃掉父 ui 剩下的宽度**(egui grid.rs:144-154 +
2046    // Grid::num_columns 的注释)→ 「量」贴到面板右缘,价与量之间空出一大截。旧口径是
2047    // TableBuilder 三列 `Column::auto()`(列宽 = 内容)→ 这里不给列数,列宽回到内容驱动
2048    // (0.4.1 实锤 t8:十行里只有 3 个量格还在 x<280 的盘口块里)。
2049    egui::Grid::new("book_grid")
2050        .spacing([4.0, 0.0])
2051        .show(ui, |grid| {
2052            for (k, (p, v)) in asks.iter().enumerate() {
2053                book_row(grid, format!("卖{}", 5 - k), ask_color, *p, *v);
2054                grid.end_row();
2055            }
2056            for (k, (p, v)) in bids.iter().enumerate() {
2057                book_row(grid, format!("买{}", k + 1), bid_color, *p, *v);
2058                grid.end_row();
2059            }
2060        });
2061}
2062
2063// ================= 数据格(2×2;0.4.1 回原生:tab 条在 render_topleft_group 等) =================
2064
2065/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
2066/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
2067/// 活动行撤单钮 + 双击撤单,终态行只读)。
2068fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
2069    let ts = shared.trade_snap();
2070    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
2071    let known_gws = account_gateways(shared);
2072    // 表题撤除(第五轮审计):格身份就是 dock tab,再挂同名 label = 三重撞名
2073    // (下单钮/tab/表题),纯冗余。
2074    let rows: Vec<_> = ts
2075        .orders
2076        .iter()
2077        .filter(|o| !active_only || order_active(o.status))
2078        .collect();
2079    // 委托表列头单源(空/非空两分支同源,0.2.7 审计口径)。
2080    // 16 列(2026-10-28 用户裁定:删「操作」撤单钮列 —— 撤单走行双击与全撤,
2081    // Python OrderMonitor 同口径;列序照 Python 16 列蓝图)。
2082    const ORDER_HEADERS: [&str; 16] = [
2083        "代码",
2084        "名称",
2085        "交易所",
2086        "类型",
2087        "方向",
2088        "开平",
2089        "价格",
2090        "总量",
2091        "已成",
2092        "成交均价",
2093        "状态",
2094        "状态描述",
2095        "时间",
2096        "委托号",
2097        "来源",
2098        "接口",
2099    ];
2100    // 0.3.3(收 Wave D 欠账):委托表也吃「点表头排序」—— 表 id 先算出来,
2101    // 空态与非空态共用一份(排序键住在 UiShared,按表 id 分槽)。
2102    let table_id = if active_only {
2103        "active_grid"
2104    } else {
2105        "order_grid"
2106    };
2107    let sort = shared.table_sort(table_id);
2108    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59):body 收出 0 行 = 空表,
2109    // 列宽由 `pinned_table_impl_cols` 按表头比例填满。
2110    // 排序只作用在「喂表前的快照副本」上(W2 口径,数据模型不被界面排序改写)。
2111    let rows = sorted_rows(&rows, sort, |o, col| order_sort_key(o, col));
2112    let clicked = pinned_table_sorted(ui, table_id, &ORDER_HEADERS, sort, |body, row_h| {
2113        // move 格闭包要把这几样所有权收进去 —— 预先用引用整形(move 闭包拷贝引用,
2114        // 不搬 Vec;known_gws/rows 都活到调用帧外,E0382/E0505 实撞)。
2115        let known_gws = &known_gws;
2116        for o in &rows {
2117            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
2118            // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
2119            // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
2120            let key = format!("{}.{}", o.gateway_name, o.orderid);
2121            let row_sel = shared.row_selected("orders", &key);
2122            body.row(row_h, row_sel, |r| {
2123                macro_rules! cell {
2124                    ($make:expr, $col:expr) => {{
2125                        // key 是**行内局部**:格延迟画笔要活到 show,必须自带 key 的
2126                        // 所有权(每格一份克隆;借用会随 add_body 帧悬空,E0373 实撞)。
2127                        let key = key.clone();
2128                        r.cell(move |ui, ctx| {
2129                            let (cell_r, inner_r) =
2130                                pin_cell(ui, ctx, $make);
2131                            let hit =
2132                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
2133                            if hit {
2134                                shared.select_row("orders", &key);
2135                            }
2136                            let dbl = cell_r.double_clicked()
2137                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
2138                            if dbl && order_active(o.status) {
2139                                // 双击撤单同按钮口径:跟着这笔单自己的网关(空串回落默认账户阶梯)。
2140                                let gw = if !o.gateway_name.is_empty() {
2141                                    o.gateway_name.clone()
2142                                } else {
2143                                    shared.default_gateway().unwrap_or_default()
2144                                };
2145                                if !gw.is_empty() {
2146                                    shared.push_cmd(UiCmd::CancelOrder {
2147                                        orderid: o.orderid.clone(),
2148                                        gateway: gw,
2149                                    });
2150                                }
2151                            }
2152                        });
2153                    }};
2154                }
2155                cell!(|ui| ui.label(o.symbol.clone()), 0);
2156                cell!(
2157                    |ui| ui.label(if o.name.is_empty() {
2158                        // 0.11.0 第5刀 R6:名称兜底 = 代码(不是「-」;行永不因缺名被丢)
2159                        o.symbol.clone()
2160                    } else {
2161                        o.name.clone()
2162                    }),
2163                    1
2164                );
2165                cell!(|ui| ui.label(o.exchange.as_str().to_string()), 2);
2166                cell!(|ui| ui.label(o.order_type.as_str().to_string()), 3);
2167                cell!(
2168                    |ui| ui.label(
2169                        egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
2170                            if o.direction == Some(Direction::SHORT) {
2171                                crate::tokens::fall(ui.ctx())
2172                            } else {
2173                                crate::tokens::rise(ui.ctx())
2174                            },
2175                        ),
2176                    ),
2177                    4
2178                );
2179                cell!(|ui| ui.label(o.offset.as_str().to_string()), 5);
2180                cell!(
2181                    |ui| ui.label(crate::tokens::num_text(crate::format::price(o.price))),
2182                    6
2183                );
2184                cell!(
2185                    |ui| ui.label(crate::tokens::num_text(crate::format::volume(o.volume))),
2186                    7
2187                );
2188                cell!(
2189                    |ui| ui.label(crate::tokens::num_text(crate::format::volume(o.traded))),
2190                    8
2191                );
2192                cell!(
2193                    |ui| ui.label(crate::tokens::num_text(crate::format::price(
2194                        o.traded_price
2195                    ))),
2196                    9
2197                );
2198                cell!(
2199                    |ui| ui.label(egui::RichText::new(o.status.as_str()).color(
2200                        if o.status == ek_data::constant::Status::REJECTED {
2201                            crate::tokens::rise(ui.ctx())
2202                        } else if o.status == ek_data::constant::Status::ALLTRADED {
2203                            crate::tokens::fall(ui.ctx())
2204                        } else {
2205                            crate::tokens::warn(ui.ctx())
2206                        },
2207                    )),
2208                    10
2209                );
2210                cell!(|ui| ui.label(o.status_msg.clone()), 11);
2211                cell!(
2212                    |ui| ui.label(crate::format::time_hms_millis(o.datetime)),
2213                    12
2214                );
2215                cell!(|ui| ui.label(o.orderid.clone()), 13);
2216                cell!(
2217                    |ui| ui.label(if o.reference.is_empty() {
2218                        "-".to_string()
2219                    } else {
2220                        o.reference.clone()
2221                    }),
2222                    14
2223                );
2224                cell!(
2225                    |ui| ui.label(ledger_gw_label(&o.gateway_name, known_gws)),
2226                    15
2227                );
2228                // 2026-10-28 用户裁定:原 17 列的「操作」撤单钮列删除。行内撤单 = 双击
2229                // (本行下面那支响应,Python OrderMonitor itemDoubleClicked 等价),
2230                // 下单面板「全撤」与账户窗「先撤 N 笔」走同一 oms 通道,撤单能力不减档。
2231            });
2232        }
2233    });
2234    if let Some(col) = clicked {
2235        shared.toggle_table_sort(table_id, col);
2236    }
2237}
2238
2239/// 委托行的排序键(列序 == `ORDER_HEADERS`):数字列给零填充串保证字典序 == 数值序
2240/// (`价格/总量/已成/成交均价`),时间列给纳秒零填充,其余给该列的文本。
2241/// 0.3.3 收 Wave D 欠账时新增 —— 与成交表的 `trade_sort_key` 同一口径。
2242fn order_sort_key(o: &ek_data::order::OrderData, col: usize) -> String {
2243    match col {
2244        0 => o.symbol.clone(),
2245        1 => o.name.clone(),
2246        2 => o.exchange.as_str().to_string(),
2247        3 => o.order_type.as_str().to_string(),
2248        4 => o
2249            .direction
2250            .map(|d| d.as_str().to_string())
2251            .unwrap_or_default(),
2252        5 => o.offset.as_str().to_string(),
2253        6 => format!("{:020.4}", o.price),
2254        7 => format!("{:020.4}", o.volume),
2255        8 => format!("{:020.4}", o.traded),
2256        9 => format!("{:020.4}", o.traded_price),
2257        10 => o.status.as_str().to_string(),
2258        11 => o.status_msg.clone(),
2259        12 => o.datetime.map(|ns| format!("{ns:025}")).unwrap_or_default(),
2260        13 => o.orderid.clone(),
2261        14 => o.reference.clone(),
2262        15 => o.gateway_name.clone(),
2263        _ => String::new(),
2264    }
2265}
2266
2267/// 成交格(12 列逐值 = Python TradeMonitor 蓝图)。
2268pub(crate) fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
2269    // 单 tab(Python `tab_widget2.addTab(trade_widget, "成交")`:那区也是 QTabWidget,
2270    // 只有一个 tab 也带 tab 头 —— BD-UI-41 补齐;0.4.0 删过,0.4.1 回归原生后恢复)。
2271    tab_label(ui, true, "成交");
2272    let ts = shared.trade_snap();
2273    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
2274    let known_gws = account_gateways(shared);
2275    // 成交表列头单源(空/非空两分支同源,0.2.7 审计口径)。
2276    // 0.3.3 Wave F(BD-UI-51):列序照 Python `TradeMonitor.headers`(widget.py:1270-1283)——
2277    // 代码在首列,成交号在第 10 列;原来我们把「成交号」放首列,是差集里唯一一条纯 UI 差集。
2278    // 列名「成交额」→「成交金额」同 Python。
2279    const TRADE_HEADERS: [&str; 12] = [
2280        "代码",
2281        "名称",
2282        "交易所",
2283        "方向",
2284        "开平",
2285        "价格",
2286        "数量",
2287        "成交金额",
2288        "时间",
2289        "成交号",
2290        "委托号",
2291        "接口",
2292    ];
2293    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59,同未成交口径)。
2294    let sort = shared.table_sort("trade_grid");
2295    let rows = sorted_rows(&ts.trades, sort, trade_sort_key);
2296    let clicked = pinned_table_sorted(ui, "trade_grid", &TRADE_HEADERS, sort, |body, row_h| {
2297        // move 格闭包要把这几样所有权收进去 —— 预先引用整形(同委托表口径)。
2298        let rows = &rows;
2299        let known_gws = &known_gws;
2300        for t in rows.iter().copied() {
2301            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
2302            // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
2303            let key = format!("{}.{}", t.gateway_name, t.tradeid);
2304            let row_sel = shared.row_selected("trades", &key);
2305            body.row(row_h, row_sel, |r| {
2306                macro_rules! cell {
2307                    ($make:expr, $col:expr) => {{
2308                        // key 是行内局部:格延迟画笔要活到 show,每格自备克隆(见委托表)。
2309                        let key = key.clone();
2310                        r.cell(move |ui, ctx| {
2311                            let (cell_r, inner_r) =
2312                                pin_cell(ui, ctx, $make);
2313                            let hit =
2314                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
2315                            if hit {
2316                                shared.select_row("trades", &key);
2317                            }
2318                            // 0.3.1(Wave D,BD-UI-49)双击联动:双击成交行 = 该合约进下单面板
2319                            // (Python 双击成交/持仓行带入下单,`mainwindow.py:276` 等价)。
2320                            let dbl = cell_r.double_clicked()
2321                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
2322                            if dbl {
2323                                let mut f = shared.order_form();
2324                                f.symbol = t.symbol.clone();
2325                                if let Some(d) = t.direction {
2326                                    f.direction = d;
2327                                }
2328                                shared.set_order_form(f);
2329                            }
2330                            // 0.3.1(Wave D)右键菜单(照 Python 表上右键:调整列宽 / 保存数据)。
2331                            // 只挂首列 = 一行一颗菜单,不在每格挂一颗。
2332                            // 0.3.3(收 Wave D 欠账):菜单挂在**真控件**上 —— 首格内容是
2333                            // `Label::new(..).sense(Sense::click())`(有 AccessKit 动作的真控件),
2334                            // 菜单由它自己的 `context_menu` 开。
2335                            // 0.4.1 五期(BD-UI-58):菜单里的「调整列宽」退役 —— 列边界可拖
2336                            // 与列线现在是全部表的固定形制(见 `pinned_table_impl_cols`),
2337                            // 一个只影响这一张表、还只影响「能不能拖」的开关成了空壳钮。
2338                        });
2339                    }};
2340                }
2341                // 首格是**真控件**(可点 + 可右键):右键菜单挂在这颗 Label 上。
2342                cell!(
2343                    |ui| {
2344                        let r = ui
2345                            .add(egui::Label::new(t.symbol.clone()).sense(egui::Sense::click()))
2346                            .on_hover_text("右键:保存数据");
2347                        r.context_menu(|ui| {
2348                            if ui.button("保存数据").clicked() {
2349                                let _ =
2350                                    save_table_csv("trade_grid", &TRADE_HEADERS, rows, trade_cells);
2351                                ui.close();
2352                            }
2353                        });
2354                        r
2355                    },
2356                    0
2357                );
2358                cell!(
2359                    |ui| ui.label(if t.name.is_empty() {
2360                        // 0.11.0 第5刀 R6:名称兜底 = 代码(不是「-」;行永不因缺名被丢)
2361                        t.symbol.clone()
2362                    } else {
2363                        t.name.clone()
2364                    }),
2365                    1
2366                );
2367                cell!(|ui| ui.label(t.exchange.as_str().to_string()), 2);
2368                cell!(
2369                    |ui| ui.label(
2370                        egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
2371                            if t.direction == Some(Direction::SHORT) {
2372                                crate::tokens::fall(ui.ctx())
2373                            } else {
2374                                crate::tokens::rise(ui.ctx())
2375                            },
2376                        ),
2377                    ),
2378                    3
2379                );
2380                cell!(|ui| ui.label(t.offset.as_str().to_string()), 4);
2381                cell!(
2382                    |ui| ui.label(crate::tokens::num_text(crate::format::price(t.price))),
2383                    5
2384                );
2385                cell!(
2386                    |ui| ui.label(crate::tokens::num_text(crate::format::volume(t.volume))),
2387                    6
2388                );
2389                cell!(
2390                    |ui| ui.label(crate::tokens::num_text(crate::format::price(
2391                        t.traded_amount
2392                    ))),
2393                    7
2394                );
2395                // 「时间」列(第 8 列)0.8.0 **不进等宽改造**:这一列旧口径就是左对齐
2396                // (不在六张表的右对齐格位清单里),本轮改造范围 = 旧右对齐格位 + 盘口
2397                // (方案 §5 的 R8 范围钉)。实撞:给它上等宽(FONT_MONO 15px)把列撑宽 →
2398                // 列被推出视口 → 判据 W6 的标签数变 0。
2399                cell!(|ui| ui.label(crate::format::time_hms_millis(t.datetime)), 8);
2400                cell!(|ui| ui.label(t.tradeid.clone()), 9);
2401                cell!(|ui| ui.label(t.orderid.clone()), 10);
2402                cell!(
2403                    |ui| ui.label(ledger_gw_label(&t.gateway_name, known_gws)),
2404                    11
2405                );
2406            });
2407        }
2408        // 右键菜单已挂在第 1 格那颗真控件上(见体表 cell!(.., 0))。
2409    });
2410    if let Some(col) = clicked {
2411        shared.toggle_table_sort("trade_grid", col);
2412    }
2413}
2414
2415/// 持仓格(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
2416/// Python mainwindow.py:276 等价,不开 K线)。
2417pub(crate) fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
2418    // 单 tab(Python `tab_widget3.addTab(position_widget, "持仓")`;BD-UI-41 补齐,
2419    // 0.4.0 删过,0.4.1 回归原生后恢复)。
2420    tab_label(ui, true, "持仓");
2421    let ts = shared.trade_snap();
2422    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
2423    let known_gws = account_gateways(shared);
2424    // 持仓表列头单源(空/非空两分支同源,0.2.7 审计口径)。
2425    // 11 列(2026-10-28 用户裁定:撤五个恒 0 死列「可用/在途/市值/最新价/盈亏比」
2426    // —— 网关直推那份不填它们;BD-UI-71/L2117 那条挂账由此收案。「名称」列保留,
2427    // 名称富化仍是 C22 的在册骨架)。列序照 python PositionMonitor 缩编版。
2428    const POS_HEADERS: [&str; 11] = [
2429        "代码",
2430        "名称",
2431        "交易所",
2432        "方向",
2433        "数量",
2434        "昨仓",
2435        "冻结",
2436        "成本",
2437        "均价",
2438        "盈亏",
2439        "接口",
2440    ];
2441    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59,同未成交口径)。
2442    // 0.3.3(收 Wave D 欠账):持仓表也吃「点表头排序」。
2443    let sort = shared.table_sort("pos_grid");
2444    let rows = sorted_rows(&ts.positions, sort, pos_sort_key);
2445    let clicked = pinned_table_sorted(ui, "pos_grid", &POS_HEADERS, sort, |body, row_h| {
2446        // move 格闭包要把这几样所有权收进去 —— 预先引用整形(同委托表口径)。
2447        let known_gws = &known_gws;
2448        for p in &rows {
2449            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
2450            // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
2451            // 不开 K线;方向随持仓方向,Python 同口径)。
2452            let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
2453            let row_sel = shared.row_selected("positions", &key);
2454            body.row(row_h, row_sel, |r| {
2455                macro_rules! cell {
2456                    ($make:expr, $col:expr) => {{
2457                        // key 是行内局部:格延迟画笔要活到 show,每格自备克隆(见委托表)。
2458                        let key = key.clone();
2459                        r.cell(move |ui, ctx| {
2460                            let (cell_r, inner_r) =
2461                                pin_cell(ui, ctx, $make);
2462                            let hit =
2463                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
2464                            if hit {
2465                                shared.select_row("positions", &key);
2466                            }
2467                            let dbl = cell_r.double_clicked()
2468                                || inner_r.as_ref().is_some_and(|r| r.double_clicked());
2469                            if dbl {
2470                                let mut f = shared.order_form();
2471                                f.symbol = p.symbol.clone();
2472                                f.exchange = p.exchange.as_str().to_string();
2473                                f.direction = p.direction;
2474                                // B1 修:行级网关带入(旧实现不带网关 → 平掉哪条腿由上一单残留决定)。
2475                                if !p.gateway_name.is_empty() {
2476                                    f.gateway = p.gateway_name.clone();
2477                                }
2478                                shared.set_order_form(f);
2479                                shared.log_line(format!(
2480                                    "[ops] 持仓带入下单面板 {}(接口 {})",
2481                                    p.symbol, p.gateway_name
2482                                ));
2483                            }
2484                        });
2485                    }};
2486                }
2487                cell!(|ui| ui.label(p.symbol.clone()), 0);
2488                cell!(
2489                    |ui| ui.label(if p.name.is_empty() {
2490                        // 0.11.0 第5刀 R6:名称兜底 = 代码
2491                        p.symbol.clone()
2492                    } else {
2493                        p.name.clone()
2494                    }),
2495                    1
2496                );
2497                cell!(|ui| ui.label(p.exchange.as_str().to_string()), 2);
2498                cell!(
2499                    |ui| ui.label(egui::RichText::new(p.direction.as_str()).color(
2500                        if p.direction == ek_data::constant::Direction::SHORT {
2501                            crate::tokens::fall(ui.ctx())
2502                        } else {
2503                            crate::tokens::rise(ui.ctx())
2504                        },
2505                    )),
2506                    3
2507                );
2508                cell!(
2509                    |ui| ui.label(crate::tokens::num_text(crate::format::volume(p.volume))),
2510                    4
2511                );
2512                cell!(
2513                    |ui| ui.label(crate::tokens::num_text(crate::format::volume(p.yd_volume))),
2514                    5
2515                );
2516                cell!(
2517                    |ui| ui.label(crate::tokens::num_text(crate::format::volume(p.frozen))),
2518                    6
2519                );
2520                // 2026-10-28 用户裁定:撤五个恒 0 死列(可用 can_use_volume / 在途
2521                // on_road_volume / 市值 market_value / 最新价 last_price / 盈亏比
2522                // profit_rate)—— 网关直推那份不填它们,挂账收案见 CHANGELOG 0.8.5。
2523                // 0.3.3 Wave F(BD-UI-51)两处收口:
2524                // ① 成本 / 均价原来**取值互换** —— ek-data 里 `avg_price` = 成本价、
2525                //    `price` = 持仓均价(position.rs:22-23 / 38-39),Python 也是
2526                //    成本 ← avg_price、均价 ← price(widget.py:1491-1492)。判据 F2 抓到这条。
2527                // ② 五处裸 `format!("{:.2}")` / `{:+.1}` / `{:+.4}` 改走 `format.rs`
2528                //    (Python BaseCell 是 `round(content, 3)`,同一张表里三种精度就是毛病 3)。
2529                cell!(
2530                    |ui| ui.label(crate::tokens::num_text(crate::format::price(p.avg_price))),
2531                    7
2532                );
2533                cell!(
2534                    |ui| ui.label(crate::tokens::num_text(crate::format::price(p.price))),
2535                    8
2536                );
2537                cell!(
2538                    |ui| ui.label(crate::tokens::num_text(crate::format::price(p.pnl)).color(
2539                        if p.pnl < 0.0 {
2540                            crate::tokens::fall(ui.ctx())
2541                        } else {
2542                            crate::tokens::rise(ui.ctx())
2543                        }
2544                    ),),
2545                    9
2546                );
2547                cell!(
2548                    |ui| ui.label(ledger_gw_label(&p.gateway_name, known_gws)),
2549                    10
2550                );
2551            });
2552        }
2553    });
2554    if let Some(col) = clicked {
2555        shared.toggle_table_sort("pos_grid", col);
2556    }
2557}
2558
2559/// 持仓行的排序键(列序 == `POS_HEADERS`;与屏幕取值同源:
2560/// 成本 = `avg_price`、均价 = `price`,0.3.3 Wave F 修的那一对)。
2561fn pos_sort_key(p: &ek_data::position::PositionData, col: usize) -> String {
2562    match col {
2563        0 => p.symbol.clone(),
2564        1 => p.name.clone(),
2565        2 => p.exchange.as_str().to_string(),
2566        3 => p.direction.as_str().to_string(),
2567        4 => format!("{:020.4}", p.volume),
2568        5 => format!("{:020.4}", p.yd_volume),
2569        6 => format!("{:020.4}", p.frozen),
2570        7 => format!("{:020.4}", p.avg_price),
2571        8 => format!("{:020.4}", p.price),
2572        9 => format!("{:020.4}", p.pnl),
2573        10 => p.gateway_name.clone(),
2574        _ => String::new(),
2575    }
2576}
2577
2578/// 资金格(0.4.0:独立 dock 格 —— 原来它是「资金|日志」组内 tab 的一半)。
2579pub(crate) fn render_funds_pane(ui: &mut egui::Ui, shared: &UiShared) {
2580    let ts = shared.trade_snap();
2581    // 0.2.29(BD-UI-44):资金表跟着日志表换 TableBuilder —— 同一套固定表头 + 按内容
2582    // 列宽;权益/可用/冻结是数字列(右对齐成列)。「余额」改名「权益」
2583    // (2026-10-28 用户裁定:CTP Balance 是盯市权益,不是静态余额)。
2584    //
2585    // 0.4.0 修正(用户实看:「资金…表头都没有」):空态**不得吞掉表头**。原来这里
2586    // `if accounts.is_empty() { muted_label; return; }` —— 没有网关时整张表连表头一起
2587    // 消失,而它就是「资金|日志」格默认前台的那一格,用户第一眼看到的就是一句空态。
2588    // 口径统一成「表头恒在」(委托/成交/持仓/日志同此);0.4.1 六期(BD-UI-59)起空态
2589    // 那一行提示文字也撤了 —— 表体就是空的,列宽按表头比例填满。
2590    pinned_table_impl(
2591        ui,
2592        "acct_grid",
2593        &["账号", "接口", "权益", "可用", "冻结"],
2594        None,
2595        |body, row_h| {
2596            for a in &ts.accounts {
2597                body.row(row_h, false, |r| {
2598                    macro_rules! cell {
2599                        ($make:expr, $col:expr) => {{
2600                            r.cell(|ui, ctx| {
2601                                let _ = pin_cell(ui, ctx, $make);
2602                            });
2603                        }};
2604                    }
2605                    cell!(|ui| ui.label(a.accountid.clone()), 0);
2606                    cell!(|ui| ui.label(a.gateway_name.clone()), 1);
2607                    // 0.3.3 Wave F:三处裸 `{:.2}` 改走 `format.rs`(同一口径,毛病 3)。
2608                    cell!(
2609                        |ui| ui.label(crate::tokens::num_text(crate::format::price(a.balance))),
2610                        2
2611                    );
2612                    cell!(
2613                        |ui| ui.label(crate::tokens::num_text(crate::format::price(a.available()))),
2614                        3
2615                    );
2616                    cell!(
2617                        |ui| ui.label(crate::tokens::num_text(crate::format::price(a.frozen))),
2618                        4
2619                    );
2620                });
2621            }
2622        },
2623    );
2624}
2625
2626/// 日志表(BD-UI-41,对照 Python 版)。
2627///
2628/// Python 侧「日志」tab 里放的是 `LogMonitor` —— 一个带 **时间/信息/接口** 表头的
2629/// QTableWidget(`widget.py:1167-1180`,BaseMonitor 子类),跟着 Dock 一起伸展。
2630/// 我们原来是一坨 monospace 行 + `ScrollArea::max_height(180.0)` 硬顶:窗口最大化
2631/// 它还是 180px —— 用户报的「整体放到最大,日志那边表格还是很小」根因就是那 180。
2632/// 现在走 `pinned_table`(与委托/成交/持仓同一套容器:表头固定 + 体滚 + 横滚),
2633/// 硬顶撤除 → 格多大,表多大。
2634///
2635/// 两处偏离(记 DESIGN BD-UI-41):
2636/// ① Python 有第三列「接口」;我们的 `log_line` 调用点不带网关(正文自带 `[ops]` /
2637///   `[订阅]` 这类前缀),硬造一列空列不如不造,所以只有两列。
2638/// ② `stick_to_bottom` 撤除:Python 的 QTableWidget 也不自动跟到最后一行,
2639///   要看最新行自己滚到底(与那张表同口径)。
2640fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
2641    // 「清空日志」钮已撤除(0.4.1 用户裁定 2026-10-22:日志面不要这个钮)。
2642    // 状态层的 `UiShared::clear_logs()` 保留(它是状态口,测试与后续「日志面重置」
2643    // 都用得上),只是界面不再给入口。
2644    let (entries, spilled) = shared.logs_entries();
2645    if spilled > 0 {
2646        // 口径写明(G1 实连证据的收口):回扫日志取证不可靠 —— 巡回里「重加后 90 秒
2647        // 没抓到『交易服务器登录成功』行」不是没登录,是这一环被状态推送流挤满了。
2648        // 登录/掉线/查询被拒这些事实的事实源是账户面板的状态列与状态细节(网关状态口),
2649        // 不是日志窗。单独一行,不挤在钮旁:长文案在横向行里会被压(s39 那类实锤)。
2650        ui.label(format!(
2651            "已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里;全量在盘:%APPDATA%\\ek\\logs\\ekapp-<日>.log",
2652            crate::state::RING_CAP
2653        ));
2654    }
2655    // BD-UI-55(egui_table 迁入):日志正文逐帧生长 → 列宽走**接管版**(字体量串,
2656    // egui galley 缓存受益;不走库 per-frame auto-size,那条路 = 每帧双 pass,
2657    // 行动作用闭包会跑两遍 —— 适配层注释)。时间列定宽 90,信息列 = 最宽正文。
2658    let mono_font = egui::TextStyle::Monospace.resolve(ui.style());
2659    // 空态不接管列宽 —— 走「按表头比例分配」那条路(BD-UI-58)。
2660    let widths: Option<Vec<f32>> = if entries.is_empty() {
2661        None
2662    } else {
2663        Some(vec![
2664            {
2665                let g = ui.painter().layout_no_wrap(
2666                    "18:00:00.000".to_string(),
2667                    mono_font.clone(),
2668                    egui::Color32::TRANSPARENT,
2669                );
2670                g.size().x + 12.0
2671            },
2672            {
2673                let mut w: f32 = 120.0;
2674                for e in &entries {
2675                    let g = ui.painter().layout_no_wrap(
2676                        e.text.clone(),
2677                        mono_font.clone(),
2678                        egui::Color32::TRANSPARENT,
2679                    );
2680                    w = w.max(g.size().x + 12.0);
2681                }
2682                w
2683            },
2684        ])
2685    };
2686    pinned_table_impl_cols(
2687        ui,
2688        "log_grid",
2689        &["时间", "信息"],
2690        None,
2691        widths,
2692        |body, row_h| {
2693            for e in &entries {
2694                body.row(row_h, false, |r| {
2695                    r.cell(|ui, _ctx| {
2696                        ui.monospace(e.at.clone());
2697                    });
2698                    r.cell(|ui, _ctx| {
2699                        ui.monospace(e.text.clone());
2700                    });
2701                });
2702            }
2703        },
2704    );
2705}
2706
2707// ================= 独立窗面(BD-UI-32/33)=================
2708
2709/// 独立窗几何(BD-UI-34:助手参数收敛,8→6 闸 clippy too_many_arguments)。
2710struct WinGeom {
2711    inner: [f32; 2],
2712    min_inner: [f32; 2],
2713    /// 0.9.10(用户裁定「开多少大,最大只能那么大,不能超过最大」):自适应的
2714    /// **天花板** —— want 被夹进 [min_inner, max_inner];内容超天花板 =
2715    /// 窗停在 max_inner,正文出滚动(不再长窗,也不许越过屏幕工作区)。
2716    max_inner: [f32; 2],
2717    maximized: bool,
2718    /// 0.9.10(用户裁定「窗口随内容自动调整」):Immediate 窗按内容实测尺寸
2719    /// 下发 `ViewportCommand::InnerSize`(仅变化 >0.5px 时发,防 resize 风暴)。
2720    fit: bool,
2721}
2722
2723/// 独立 OS 窗统一开法(BD-UI-33,四窗一型:运维/CTP/查询合约/K线)。
2724///
2725/// - **守卫(0.2.3 教训内建):** 调用方按状态位辞行——位关不调本函数 =
2726///   viewport 本拍未 used = egui/eframe GC 销毁 OS 窗(销毁判据源码
2727///   context.rs「never used this pass」)。
2728/// - Embedded 回退(后端不支持多窗:kittest/WEB)= 主窗内 egui::Window 同本体,
2729///   回退面关闭 = embedded 闭包返回 true。
2730/// - Immediate 面 OS X = close_requested → 返回 true(调用方翻负状态位)。
2731/// - `geom.maximized` = 打开即最大化(BD-UI-34,K线窗用户裁定;OS 窗属性,
2732///   Embedded 回退面无此概念 = 忽略)。
2733/// - `body` 返回 true = 内容侧请求关闭(BD-UI-34:K线窗 ESC 键快捷关闭;
2734///   键事件按子窗焦点路由,主窗按键不会误关)。
2735///
2736/// 返回 = "本拍该窗被关"(调用方负责状态位/列表出表)。
2737fn show_independent_window(
2738    ctx: &egui::Context,
2739    id_salt: impl std::hash::Hash,
2740    title: &str,
2741    geom: &WinGeom,
2742    embedded: impl Fn(&egui::Context) -> bool,
2743    mut body: impl FnMut(&mut egui::Ui) -> bool,
2744) -> bool {
2745    let closed = std::cell::Cell::new(false);
2746    let mut builder = egui::ViewportBuilder::default()
2747        .with_title(title)
2748        .with_inner_size(geom.inner)
2749        .with_min_inner_size(geom.min_inner)
2750        .with_max_inner_size(geom.max_inner)
2751        .with_minimize_button(true)
2752        .with_maximize_button(true)
2753        .with_close_button(true);
2754    if geom.maximized {
2755        builder = builder.with_maximized(true);
2756    }
2757    ctx.show_viewport_immediate(
2758        egui::ViewportId::from_hash_of(id_salt),
2759        builder,
2760        |ctx2, class| {
2761            if class == egui::ViewportClass::Embedded {
2762                closed.set(embedded(ctx2));
2763                return;
2764            }
2765            if ctx2.input(|i| i.viewport().close_requested()) {
2766                closed.set(true);
2767                return;
2768            }
2769            // 0.9.10 自适应 = **一次性**(用户两轮实看定罪 + 裁定定稿):
2770            // 「宽度不停增加」(v1-v3)治好后仍剩两个病 —— ①每帧量尺 = body 每帧
2771            // 跑两遍;②窗尺寸被 want 长期锁定:**最大化被 InnerSize 顶掉、手动拖宽
2772            // 下一帧被吸回**。「最大化都最大不了」实锤后裁定:
2773            // **只在首次内容填充之后调一次,此后不再自动调整**。
2774            // 判据 = 量尺自然尺寸连续 3 帧不变且已过 6 帧(数据一拍 1s,首帧常是
2775            // 空态)→ 下发一次 InnerSize(夹进 [min,max])→ 本窗 fit 退役:
2776            // 量尺停跑、任何后续帧(含最大化/拖拽后)都不再发尺寸。
2777            // 状态 per-viewport(temp 随窗销毁 = 重开窗重新走一遍一次性适应)。
2778            #[derive(Clone, Copy)]
2779            struct FitOneShot {
2780                w: f32,
2781                h: f32,
2782                stable: u8,
2783                frames: u16,
2784                done: bool,
2785            }
2786            if geom.fit {
2787                let maximized = ctx2.viewport(|vp| vp.builder.maximized.unwrap_or(false));
2788                let prev = ctx2.data(|d| {
2789                    d.get_temp::<FitOneShot>(egui::Id::new(("fit_oneshot", title)))
2790                });
2791                let st = prev.unwrap_or(FitOneShot {
2792                    w: -1.0,
2793                    h: -1.0,
2794                    stable: 0,
2795                    frames: 0,
2796                    done: false,
2797                });
2798                // 最大化帧 = 用户接管尺寸:不量、不发(恢复后也不再量 = done 已置
2799                // 或不推进 = 永不发第二刀)。
2800                if !st.done && !maximized {
2801                    // 量尺纪律(v4;翻车定罪与收敛三件套见 git 史):① 小值起量
2802                    // (上帧量得的内容宽摆进 max_rect,撑满控件最多撑到量尺宽,
2803                    // 不动点 = 非撑满内容的天然宽);② 禁折行(整句宽计,Grid
2804                    // 列宽 = 天然列宽之和);③ probe **隐形** = 零动作面(点击/
2805                    // 勾选/键入零副作用),行带 paint 在 probe 里 is_visible() 为假
2806                    // → 零绘面。
2807                    let mut sc = (*ctx2.style()).clone();
2808                    sc.wrap_mode = Some(egui::TextWrapMode::Extend);
2809                    let measure_w = ctx2.data(|d| {
2810                        d.get_temp::<f32>(egui::Id::new(("fit_w", title)))
2811                            .unwrap_or(48.0)
2812                    });
2813                    let mut probe = egui::Ui::new(
2814                        ctx2.clone(),
2815                        egui::Id::new(("fit_probe", title)),
2816                        egui::UiBuilder::new()
2817                            .global_scope(true)
2818                            .invisible()
2819                            .style(sc)
2820                            .max_rect(egui::Rect::from_min_size(
2821                                egui::pos2(0.0, 0.0),
2822                                egui::vec2(measure_w.max(48.0), 1.0e5),
2823                            ))
2824                            .layout(egui::Layout::top_down(egui::Align::Min)),
2825                    );
2826                    let _ = body(&mut probe);
2827                    let nat_w = probe.min_rect().width();
2828                    let nat_h = probe.min_rect().height();
2829                    ctx2.data_mut(|d| {
2830                        d.insert_temp(egui::Id::new(("fit_w", title)), nat_w);
2831                    });
2832                    let stable =
2833                        (nat_w - st.w).abs() < 0.5 && (nat_h - st.h).abs() < 0.5;
2834                    let mut next = FitOneShot {
2835                        w: nat_w,
2836                        h: nat_h,
2837                        stable: if stable { st.stable + 1 } else { 0 },
2838                        frames: st.frames + 1,
2839                        done: false,
2840                    };
2841                    if next.stable >= 3 && next.frames >= 6 {
2842                        let want = egui::vec2(
2843                            (nat_w + 20.0).clamp(geom.min_inner[0], geom.max_inner[0]),
2844                            (nat_h + 26.0).clamp(geom.min_inner[1], geom.max_inner[1]),
2845                        );
2846                        ctx2
2847                            .send_viewport_cmd(egui::ViewportCommand::InnerSize(want));
2848                        next.done = true;
2849                    }
2850                    ctx2.data_mut(|d| {
2851                        d.insert_temp(egui::Id::new(("fit_oneshot", title)), next);
2852                    });
2853                }
2854            }
2855            egui::CentralPanel::default().show(ctx2, |ui| {
2856                if body(ui) {
2857                    closed.set(true);
2858                }
2859            });
2860        },
2861    );
2862    closed.get()
2863}
2864
2865fn render_windows(ctx: &egui::Context, shared: &UiShared) {
2866    // 运维窗(菜单「运维」;BD-UI-33 转独立 OS 窗,用户裁定「四窗皆可脱离主窗」)。
2867    if shared.ops_window_open() {
2868        let closed = show_independent_window(
2869            ctx,
2870            "ops_win",
2871            "运维窗",
2872            &WinGeom {
2873                inner: [680.0, 420.0],
2874                min_inner: [420.0, 260.0],
2875                max_inner: [1280.0, 900.0],
2876                maximized: false,
2877                fit: true,
2878            },
2879            |ctx2| {
2880                let mut open = shared.ops_window_open();
2881                egui::Window::new("运维窗")
2882                    .id(egui::Id::new("ops_win"))
2883                    .default_pos([80.0, 320.0])
2884                    .default_size([640.0, 380.0])
2885                    .open(&mut open)
2886                    .show(ctx2, |ui| {
2887                        render_ops_window(ui, shared);
2888                    });
2889                shared.set_ops_window_open(open);
2890                !open
2891            },
2892            |ui| {
2893                render_ops_window(ui, shared);
2894                false
2895            },
2896        );
2897        if closed {
2898            shared.set_ops_window_open(false);
2899        }
2900    }
2901
2902    // 账户面板(菜单「账户」;多账号面:账户清单 + 行情源选择 + 添加/编辑表单;
2903    // 旧「CTP 网关配置窗」退役收纳于此窗,BD-UI-33 独立 OS 窗形态沿用)。
2904    if shared.accounts_window_open() {
2905        let closed = show_independent_window(
2906            ctx,
2907            "account_win",
2908            "账户面板",
2909            &WinGeom {
2910                inner: [760.0, 520.0],
2911                min_inner: [520.0, 320.0],
2912                max_inner: [1280.0, 900.0],
2913                maximized: false,
2914                fit: true,
2915            },
2916            |ctx2| {
2917                let mut open = shared.accounts_window_open();
2918                egui::Window::new("账户面板")
2919                    .id(egui::Id::new("account_win"))
2920                    .default_pos([640.0, 320.0])
2921                    .default_size([720.0, 470.0])
2922                    .open(&mut open)
2923                    .show(ctx2, |ui| {
2924                        render_accounts_window(ui, shared);
2925                    });
2926                shared.set_accounts_window_open(open);
2927                !open
2928            },
2929            |ui| {
2930                render_accounts_window(ui, shared);
2931                false
2932            },
2933        );
2934        if closed {
2935            shared.set_accounts_window_open(false);
2936        }
2937    }
2938
2939    // 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
2940    // 『添加账户』与『编辑 <账户>』开同一个弹窗的两种模式;**非 modal**(不阻塞主窗与行情观察)。
2941    // 关闭(窗 × 或『取消』)= 放弃未保存(规划 Q24),绝不静默写入清册留档。
2942    if shared.account_edit_open() {
2943        let closed = show_independent_window(
2944            ctx,
2945            "account_edit_win",
2946            "账户配置",
2947            &WinGeom {
2948                inner: [680.0, 340.0],
2949                min_inner: [480.0, 240.0],
2950                max_inner: [900.0, 780.0],
2951                maximized: false,
2952                fit: true,
2953            },
2954            |ctx2| {
2955                let mut open = shared.account_edit_open();
2956                let title = match shared.editing_account() {
2957                    Some(gw) => format!("账户配置 — 编辑 {gw}"),
2958                    None => "账户配置 — 添加".to_string(),
2959                };
2960                egui::Window::new(title)
2961                    .id(egui::Id::new("account_edit_win"))
2962                    .default_pos([700.0, 380.0])
2963                    .default_size([660.0, 320.0])
2964                    .open(&mut open)
2965                    .show(ctx2, |ui| {
2966                        render_account_edit_window(ui, shared);
2967                    });
2968                if !open {
2969                    shared.end_account_edit();
2970                }
2971                !open
2972            },
2973            |ui| {
2974                render_account_edit_window(ui, shared);
2975                false
2976            },
2977        );
2978        if closed && shared.account_edit_open() {
2979            shared.end_account_edit();
2980        }
2981    }
2982
2983    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)。
2984    if shared.contracts_window_open() {
2985        let closed = show_independent_window(
2986            ctx,
2987            "contracts_win",
2988            "查询合约窗",
2989            &WinGeom {
2990                inner: [620.0, 460.0],
2991                min_inner: [420.0, 260.0],
2992                max_inner: [1280.0, 900.0],
2993                maximized: false,
2994                fit: true,
2995            },
2996            |ctx2| {
2997                let mut open = shared.contracts_window_open();
2998                egui::Window::new("查询合约窗")
2999                    .id(egui::Id::new("contracts_win"))
3000                    .default_pos([420.0, 300.0])
3001                    .default_size([560.0, 420.0])
3002                    .open(&mut open)
3003                    .show(ctx2, |ui| {
3004                        render_contracts_window(ui, shared);
3005                    });
3006                shared.set_contracts_window_open(open);
3007                !open
3008            },
3009            |ui| {
3010                render_contracts_window(ui, shared);
3011                false
3012            },
3013        );
3014        if closed {
3015            shared.set_contracts_window_open(false);
3016        }
3017    }
3018
3019    // K线窗(双击弹出,按合约多开;出 kline_symbols 列表 = 不再 show = 销毁)。
3020    // BD-UI-34:打开即最大化(maximized)+ ESC 键快捷关闭(body 请求关闭;
3021    // 键按子窗焦点路由,主窗按键不误关)。
3022    let mut closed: Vec<String> = Vec::new();
3023    for sym in shared.kline_symbols() {
3024        let is_closed = show_independent_window(
3025            ctx,
3026            format!("kline_win:{sym}"),
3027            &format!("K线 — {sym}"),
3028            &WinGeom {
3029                inner: [560.0, 360.0],
3030                min_inner: [360.0, 220.0],
3031                // K线窗打开即最大化(BD-UI-34 用户裁定),fit 对 maximized 窗跳过;
3032                // 天花板照给:手动还原后拖拽不许越过工作区。
3033                max_inner: [1600.0, 1000.0],
3034                maximized: true,
3035                fit: false,
3036            },
3037            |ctx2| {
3038                let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
3039                let resp = egui::Window::new(format!("K线 — {sym}"))
3040                    .id(egui::Id::new(("kline_win", sym.clone())))
3041                    .default_pos([300.0, 340.0])
3042                    .default_size([540.0, 330.0])
3043                    .show(ctx2, |ui| {
3044                        let cell = shared.market().find_cell(&sym);
3045                        render_kline_cell(ui, cell.as_ref());
3046                    });
3047                esc || resp.is_none()
3048            },
3049            |ui| {
3050                if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
3051                    return true;
3052                }
3053                let cell = shared.market().find_cell(&sym);
3054                render_kline_cell(ui, cell.as_ref());
3055                false
3056            },
3057        );
3058        if is_closed {
3059            closed.push(sym);
3060        }
3061    }
3062    for s in closed {
3063        shared.close_kline(&s);
3064    }
3065}
3066
3067/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
3068/// + 依赖图。回灌界面面已退役(总线口保留;流控查询钮 0.2.10 随 sim 退场撤除)。
3069fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
3070    crate::ui_ext::section_title(ui, "插件");
3071    // 0.5 M3:装配显形改吃事件(启动 / `ePlugUp.oms`),这里只留一个手动口子。
3072    // 为什么放在「插件」节而不是「oms 管线」节:问不到时那一节整节不画(s87b 口径),
3073    // 钮挂在那儿就等于「有读数才给重问」—— 恰恰在没读数的时候最需要重问。
3074    ui.horizontal(|ui| {
3075        if ui.small_button(BTN_PIPELINE_REFRESH).clicked() {
3076            shared.request_pipeline_refresh();
3077        }
3078        ui.label(
3079            egui::RichText::new("管线读数只在 oms 上线 / 启动 / 这里手动触发时问一次")
3080                .color(crate::tokens::info(ui.ctx())),
3081        );
3082    });
3083    let snap = shared.snapshot();
3084    // 账户清册里的实例名(一次取好,别在每行每帧各 clone 一遍整表)。
3085    let account_gateways: std::collections::HashSet<String> = shared
3086        .accounts()
3087        .iter()
3088        .map(|a| a.gateway.clone())
3089        .collect();
3090    let mut plug_heads: Vec<(egui::Rect, String)> = Vec::new();
3091    let mut plug_rows: Vec<f32> = Vec::new();
3092    // 装配锁水印(0.9.5 阶段机 S2):锁定期摘/注钮同旗 disable,水印行在场。
3093    if shared.assembly_locked() {
3094        ui.label(
3095            egui::RichText::new("已锁定(运行中):装配面只可看不可动")
3096                .color(crate::tokens::warn(ui.ctx()))
3097                .small(),
3098        );
3099    }
3100    // 列宽下限(BD-UI-60:短表表宽 = 所在区域 100%,口径与实现见 `ui_ext::grid_col_width`)。
3101    let plug_col_w = crate::ui_ext::grid_col_width(ui, "plug_grid", 6);
3102    // 行高下限与表格层同一口径(BD-UI-61:`table_heights` 同一对数,短表行高 = 主表行高)。
3103    let plug_row_h = table_heights(ui).0;
3104    let band_x1 = ui.max_rect().max.x;
3105    egui::Grid::new("plug_grid")
3106        .num_columns(6)
3107        .min_col_width(plug_col_w)
3108        .min_row_height(plug_row_h)
3109        .show(ui, |ui| {
3110            for h in ["名称", "状态", "版本", "依赖", "统计", "操作"] {
3111                // 表头 = 透明占位格,字由 header_band 画(注释见账户面板同款)。
3112                let r = ui.label(
3113                    egui::RichText::new(h)
3114                        .strong()
3115                        .small()
3116                        .color(egui::Color32::TRANSPARENT),
3117                );
3118                plug_heads.push((r.rect, h.to_string()));
3119            }
3120            ui.end_row();
3121            // 空态不再画提示行(0.4.1 六期 BD-UI-59):快照空 = 这张表只有表头那一行。
3122            for r in &snap.rows {
3123                let row_y = ui.cursor().min.y;
3124                let row_x0 = ui.cursor().min.x;
3125                // 行带跳色见 `paint_row_band`(行首注册,形制注释在那)。
3126            paint_row_band(ui, row_x0, row_y, band_x1);
3127            if is_quarantined(&r.state) {
3128                    ui.label(
3129                        egui::RichText::new(r.name.clone()).color(crate::tokens::error(ui.ctx())),
3130                    );
3131                    ui.label(
3132                        egui::RichText::new(state_text(r))
3133                            .color(crate::tokens::error(ui.ctx()))
3134                            .strong(),
3135                    );
3136                } else {
3137                    ui.label(r.name.clone());
3138                    ui.label(state_text(r));
3139                }
3140                ui.label(r.version.clone());
3141                ui.label(format!("deps:[{}]", r.deps.join(",")));
3142                // 统计列(0.4.5 P7d 首发;0.7.0 背压刀加水位):丢=软顶触发的丢弃累计,
3143                // 爆=panic 累计,积压=当前邮箱深度,峰=历史峰值,软=该插件自己的软顶。
3144                // 上色按最坏一项(判据纯函数 stat_color);只上色不改布局。
3145                ui.label(
3146                    egui::RichText::new(format!(
3147                        "丢:{} 爆:{} 积压:{} 峰:{} 软:{}",
3148                        r.spilled, r.panics, r.pending, r.peak, r.soft_cap
3149                    ))
3150                    .color(stat_color(
3151                        crate::tokens::theme_of(ui.ctx()),
3152                        r.spilled,
3153                        r.panics,
3154                        r.pending,
3155                        r.soft_cap,
3156                    )),
3157                );
3158                ui.horizontal(|ui| {
3159                    // Q17 收口(0.2.20):账户清册里的实例不在运维窗起停。
3160                    // 账户面板的『移除』带全套守卫(活跃委托闸 + 二次确认 + 持仓提示),
3161                    // 运维窗这条是同一动作的无守卫副本 —— 同一个实例两条移除路、守卫只长在
3162                    // 一条上,等于守卫可以绕过(规划 Q17 的边界就在这)。
3163                    // 清册外的 `ctp_*` 实例(手工起的 / 旧流程残留)仍留这条出口,否则没有退路。
3164                    // 0.9.10 P2b(四面美化·运维面):按钮光杆化(行即对象,同账户面板口径)
3165                    // + 说明从行内长句改**徽章 + tooltip**。
3166                    if account_gateways.contains(&r.name) {
3167                        let badge = ui.label(
3168                            egui::RichText::new("归账户面板")
3169                                .color(crate::tokens::info(ui.ctx()))
3170                                .small(),
3171                        );
3172                        badge.on_hover_text("起停归『账户』面板(带撤单守卫),运维窗不给无守卫副本");
3173                    } else if r.name == UI_NAME || r.name == SVCTL_NAME {
3174                        // 管制面与观测面自身不给移除钮(0.4.3 BD-UI-64,审核 P1)。
3175                        // 摘 svctl = 单向门:工厂表(default_kinds / ui_kinds)都不登记 "svctl",
3176                        // 摘掉后连『重注入 svctl』也只会回 unknown factory,启停面没有恢复入口。
3177                        // 摘 ui = 自己等自己:这条指令正是 ui 自己的 worker 在执行,只能走到
3178                        // ek-core close_one 的 2 秒超时 detach 分支,窗口还在但不再刷新。
3179                        // 插件侧同一道闸:supervisor 拒摘 svctl(ek-station 0.2.11)、
3180                        // UiPlugin 直拒摘 ui(本 crate plugin.rs)。
3181                        let badge = ui.label(
3182                            egui::RichText::new("自身不摘")
3183                                .color(crate::tokens::info(ui.ctx()))
3184                                .small(),
3185                        );
3186                        badge.on_hover_text(
3187                            "管制面/观测面自身:不在运维窗摘除(摘 svctl = 单向门;摘 ui = 自己等自己)",
3188                        );
3189                        if ui
3190                            .add_enabled(
3191                                !shared.assembly_locked(),
3192                                egui::Button::new("重注入").small(),
3193                            )
3194                            .clicked()
3195                        {
3196                            shared.push_cmd(UiCmd::Start {
3197                                factory: r.name.clone(),
3198                                name: String::new(),
3199                            });
3200                        }
3201                    } else {
3202                        if ui
3203                            .add_enabled(
3204                                !shared.assembly_locked(),
3205                                egui::Button::new("重注入").small(),
3206                            )
3207                            .clicked()
3208                        {
3209                            // 运维窗按「注册名 = 工厂名」的旧插件重注入(dm/oms/ui);
3210                            // 命名网关实例(`ctp_<broker>_<账号>`)不是工厂名,会被
3211                            // supervisor 回拒 unknown factory —— 那类实例的起停归「账户」面板。
3212                            shared.push_cmd(UiCmd::Start {
3213                                factory: r.name.clone(),
3214                                name: String::new(),
3215                            });
3216                        }
3217                        // 装配锁(0.9.5 阶段机):启动全成功后摘/注钮 disable ——
3218                        // 与勾选器同旗,锁不被绕过(审核定案的覆盖面)。
3219                        let armed = shared.pending_confirm();
3220                        let armed_here = ui
3221                            .add_enabled_ui(!shared.assembly_locked(), |ui| {
3222                                op_button(
3223                                    ui,
3224                                    if armed.as_deref() == Some(r.name.as_str()) {
3225                                        "确认移除?"
3226                                    } else {
3227                                        "移除"
3228                                    },
3229                                    if armed.as_deref() == Some(r.name.as_str()) {
3230                                        OpTone::Confirm
3231                                    } else {
3232                                        OpTone::Danger
3233                                    },
3234                                )
3235                                .clicked()
3236                            })
3237                            .inner
3238                            && shared.confirm_step(&r.name) == ConfirmStep::Fired;
3239                        if armed_here {
3240                            shared.push_cmd(UiCmd::Remove {
3241                                name: r.name.clone(),
3242                            });
3243                        }
3244                    }
3245                });
3246                plug_rows.push(ui.min_rect().max.y);
3247                ui.end_row();
3248            }
3249        });
3250    let plug_band = header_band(ui, &plug_heads, ui.min_rect().max.x);
3251    grid_chrome(ui, plug_band, &plug_heads, &plug_rows);
3252
3253    if let Some(name) = shared.pending_confirm() {
3254        ui.horizontal(|ui| {
3255            // 钮光杆化后行上写的是「移除」不是「移除 {name}」—— 点名放括号,
3256            // 指的路径要与行上字样一致(用户口径:按钮字样 = 所见即所点)。
3257            ui.label(
3258                egui::RichText::new(format!("待二次确认:再次点击该行的『移除』完成({name})"))
3259                    .color(crate::tokens::warn(ui.ctx())),
3260            );
3261            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
3262                shared.confirm_cancel();
3263            }
3264        });
3265    }
3266
3267    // oms 管线(0.4.4,P5-a 裁定接的那条服务):装配显形。
3268    // 插件表给「谁在场」,这一节给「oms 里面装了什么」——五段管线 / 限速与撤单守卫 /
3269    // 拆单器 / 每道风控闸一行(工厂注入 ek_risk::standard_gates 缺省限额那四道)。
3270    // 问不到(oms 不在场 → TargetGone 快错,或超时)整节不画:这里没有表、没有可空的
3271    // 东西,与六期 BD-UI-59「空态 = 表头 + 0 行」不是同一种空。
3272    let pipeline = shared.pipeline_lines();
3273    if !pipeline.is_empty() {
3274        crate::ui_ext::space_group(ui);
3275        crate::ui_ext::section_title(ui, "oms 管线");
3276        for line in &pipeline {
3277            ui.label(egui::RichText::new(line).monospace());
3278        }
3279    }
3280
3281    // 配置托底(0.6.0,BD-CFG 环节8):段落目录(零值面)+ 清段/重存段。
3282    // save 联线的另一半长在网关自己身上(`{gw}.configure` 受理成功 → 三行序,
3283    // ek-station DESIGN BD-CFG-14 的 0.10.1 扩)。这里给的是运维面:
3284    // 看段落目录(哪个 ns 有哪些键、哪些是引用)、修格式 rev、误配后抹段。
3285    crate::ui_ext::space_group(ui);
3286    crate::ui_ext::section_title(ui, "配置托底");
3287    ui.horizontal(|ui| {
3288        if ui.small_button(BTN_CFG_REFRESH).clicked() {
3289            shared.arm_cfg_delete(None);
3290            shared.request_cfg_refresh();
3291        }
3292        ui.label(
3293            egui::RichText::new(format!(
3294                "段落目录零值面(只要键名/形态;小键名 = 明面,(引用) = {} 形)",
3295                ek_cfg::legacy::REF_PREFIX
3296            ))
3297            .color(crate::tokens::info(ui.ctx())),
3298        );
3299    });
3300    match shared.cfg_briefs() {
3301        None => {
3302            ui.label(
3303                egui::RichText::new("(未问过段落目录;点『刷新段落目录』再画)")
3304                    .color(crate::tokens::muted(ui.ctx())),
3305            );
3306        }
3307        Some(briefs) if briefs.is_empty() => {
3308            ui.label(
3309                egui::RichText::new("(无段落;配置受理成功后会出现)")
3310                    .color(crate::tokens::muted(ui.ctx())),
3311            );
3312        }
3313        Some(briefs) => {
3314            let b_col_w = crate::ui_ext::grid_col_width(ui, "cfgb_grid", 4);
3315            let b_row_h = table_heights(ui).0;
3316            let band_x1 = ui.max_rect().max.x;
3317            egui::Grid::new("cfgb_grid")
3318                .num_columns(4)
3319                .min_col_width(b_col_w)
3320                .min_row_height(b_row_h)
3321                .show(ui, |ui| {
3322                    for h in ["ns", "rev", "键", "操作"] {
3323                        // 表头 = 透明占位格;这张表没挂 header_band(在滚动节里),
3324                        // 表头字面 = 占位格直画,口径照主界面 = strong+small+text3。
3325                        ui.label(
3326                            egui::RichText::new(h)
3327                                .strong()
3328                                .small()
3329                                .color(crate::tokens::theme_of(ui.ctx()).palette().text3),
3330                        );
3331                    }
3332                    ui.end_row();
3333                    for b in &briefs {
3334                        let row_y = ui.cursor().min.y;
3335                        let row_x0 = ui.cursor().min.x;
3336                        paint_row_band(ui, row_x0, row_y, band_x1);
3337                        ui.label(b.ns.clone());
3338                        ui.label(b.rev.to_string());
3339                        ui.label(format!(
3340                            "{} 条:{}",
3341                            b.keys.len(),
3342                            b.keys
3343                                .iter()
3344                                .map(|k| if k.secret {
3345                                    format!("{}(引用)", k.key)
3346                                } else {
3347                                    k.key.clone()
3348                                })
3349                                .collect::<Vec<_>>()
3350                                .join("、"),
3351                        ));
3352                        // 光杆钮(0.9.10 运维面统一,同插件表口径:行即对象,
3353                        // 二次确认的「再点一次」状态写进按钮字样,不带段名)。
3354                        ui.horizontal(|ui| {
3355                            let armed = shared.cfg_delete_armed();
3356                            let armed_here = armed.as_deref() == Some(b.ns.as_str());
3357                            let label = if armed_here { "确认清段?" } else { "清段" };
3358                            if ui.small_button(label).clicked() {
3359                                if armed_here {
3360                                    shared.arm_cfg_delete(None);
3361                                    shared.push_cmd(UiCmd::CfgDelete { ns: b.ns.clone() });
3362                                } else {
3363                                    shared.arm_cfg_delete(Some(b.ns.clone()));
3364                                }
3365                            }
3366                            if ui.small_button("重存段").clicked() {
3367                                shared.push_cmd(UiCmd::CfgResave { ns: b.ns.clone() });
3368                            }
3369                        });
3370                        ui.end_row();
3371                    }
3372                });
3373        }
3374    }
3375
3376    crate::ui_ext::space_group(ui);
3377    crate::ui_ext::section_title(ui, "服务");
3378    let flat: Vec<String> = snap
3379        .services
3380        .iter()
3381        .flat_map(|(n, ids)| {
3382            if ids.is_empty() {
3383                vec![format!("{n} 服务"), "-".to_string()]
3384            } else {
3385                ids.iter()
3386                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
3387                    .collect()
3388            }
3389        })
3390        .collect();
3391    // 表头(BD-UI-41,用户指出「配置那边表格也要有表头」):这张表原来六列纯数据没有头行,
3392    // 列语义 = 三对「服务名 / 实例 id」,头就照这个写。
3393    // 0.4.0 修正(用户实看:「运维…的表格也都要处理」):空态**不得吞掉表头**。原来空态只剩
3394    // 一句「(无服务目录)」,表头带与列宽全没了 —— 与资金格同一个病;口径 = 表头恒画。
3395    // (当时补的那句「空态是表体内的一行」到六期 BD-UI-59 撤除:空态 = 表头 + 0 行。)
3396    let mut svc_heads: Vec<(egui::Rect, String)> = Vec::new();
3397    let mut svc_rows: Vec<f32> = Vec::new();
3398    // 列宽下限同 plug_grid(BD-UI-60)。
3399    let svc_col_w = crate::ui_ext::grid_col_width(ui, "svc_grid", 6);
3400    let svc_row_h = table_heights(ui).0;
3401    let band_x1 = ui.max_rect().max.x;
3402    egui::Grid::new("svc_grid")
3403        .num_columns(6)
3404        .min_col_width(svc_col_w)
3405        .min_row_height(svc_row_h)
3406        .show(ui, |ui| {
3407            for _ in 0..3 {
3408                for h in ["服务", "实例"] {
3409                    // 表头 = 透明占位格,字由 header_band 画(注释见账户面板同款)。
3410                    let r = ui.label(
3411                        egui::RichText::new(h)
3412                            .strong()
3413                            .small()
3414                            .color(egui::Color32::TRANSPARENT),
3415                    );
3416                    svc_heads.push((r.rect, h.to_string()));
3417                }
3418            }
3419            ui.end_row();
3420            // 空态不再画提示行(0.4.1 六期 BD-UI-59):目录空 = 这张表只有表头那一行
3421            // (`rows = 0` → 循环不跑),表头带照画。
3422            let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
3423            for r in 0..rows {
3424                let row_y = ui.cursor().min.y;
3425                let row_x0 = ui.cursor().min.x;
3426                // 行带跳色见 `paint_row_band`(行首注册)。
3427            paint_row_band(ui, row_x0, row_y, band_x1);
3428            for c in 0..3 {
3429                    let i = c * rows * 2 + r * 2;
3430                    if i < flat.len() {
3431                        ui.label(flat[i].clone());
3432                        ui.label(flat[i + 1].clone());
3433                    } else {
3434                        ui.label("");
3435                        ui.label("");
3436                    }
3437                }
3438                svc_rows.push(ui.min_rect().max.y);
3439                ui.end_row();
3440            }
3441        });
3442    let svc_band = header_band(ui, &svc_heads, ui.min_rect().max.x);
3443    grid_chrome(ui, svc_band, &svc_heads, &svc_rows);
3444
3445    ui.label("依赖");
3446    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
3447        ui.label("(全部无依赖)");
3448    }
3449    for r in &snap.rows {
3450        if !r.deps.is_empty() {
3451            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
3452        }
3453    }
3454}
3455
3456/// 账户面板(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
3457/// 列表上方 = 行情源一览 + 账户计数(软上限)+ 『添加账户』入口;
3458/// 列表 = **跨账户一览**(状态 / 行情源 / 收录勾选与网关实际收录 / 行操作);
3459/// 配置表单**移入独立弹窗**(`render_account_edit_window`)—— 本窗不再内嵌表单,
3460/// 旧「窗内一张共享表单」退役(消 U4 凭据串味 / U6 取消编辑不清空的界面根)。
3461/// 账户面板操作钮的统一外观(0.9.10 用户裁定「按钮要一眼是按钮」):
3462/// 主 = 强调底(连接/添加), 素 = 面板底+描边(断开/编辑), 险 = 警示描边(移除),
3463/// 确认 = 危险实底(确认移除)。尺寸一致(min_side 26),不再用 small_button。
3464pub(crate) enum OpTone {
3465    Primary,
3466    Ghost,
3467    Danger,
3468    Confirm,
3469}
3470
3471// ================= 行带(表格行悬停跳色,0.9.10 全桌面统一)=================
3472// 主界面表(egui_table `pinned_table`)的形制 = 悬停整行 `widgets.hovered.bg_fill`
3473// 跳色(delegate `row_ui`:先画带底,格内文字后画 = 带在字下)。子窗的手搭表
3474// (账户面板 / 运维插件表 / 服务目录 / 配置托底表)与配置弹窗表单行照抄同一条:
3475// **行开画处先注册带** —— 层内按 add 序画,行首注册 = 带在整行所有图元之下,
3476// 实色 `hovered.bg_fill` 不遮字,与主界面同形制(行尾补画会压字,半透明色是
3477// 遮字缺陷的补丁而非答案,已废)。两条纪律:
3478// ① 带右缘 = 调用方给的**可视右缘**(行尾 min_rect 右缘只到最后一格内容右边,
3479//    列内容窄时带不齐;主界面 pinned_table 的带 = 表头带同宽 = 可视右缘)。
3480// ② **量尺面不画** —— fit 的 probe 每帧把 body 重跑一遍(probe 在 (0,0) 起量,
3481//    pointer 恰落其带时会往窗角贴一块可见跳色)。probe 是 invisible Ui,
3482//    `is_visible()` 为假 → 直接返回(probe 零绘面,与零动作面同一纪律)。
3483fn paint_row_band(ui: &egui::Ui, x0: f32, row_y: f32, x1: f32) {
3484    if !ui.is_visible() {
3485        return;
3486    }
3487    let band = egui::Rect::from_min_max(
3488        egui::pos2(x0, row_y),
3489        egui::pos2(x1.max(x0), row_y + table_heights(ui).0),
3490    );
3491    row_band_paint(ui, band);
3492}
3493
3494/// 带底注册(见 `paint_row_band` 纪律;矩形由调用方给全)。
3495fn row_band_paint(ui: &egui::Ui, band: egui::Rect) {
3496    if !ui.is_visible() {
3497        return;
3498    }
3499    if band.width() <= 0.5 || band.height() <= 0.5 {
3500        return;
3501    }
3502    if ui.rect_contains_pointer(band) {
3503        ui.ctx()
3504            .layer_painter(ui.layer_id())
3505            .add(egui::Shape::rect_filled(
3506                band,
3507                0.0,
3508                ui.visuals().widgets.hovered.bg_fill,
3509            ));
3510    }
3511}
3512
3513pub(crate) fn op_button(ui: &mut egui::Ui, label: &str, tone: OpTone) -> egui::Response {
3514    let v = ui.visuals();
3515    let (fill, stroke, fg) = match tone {
3516        OpTone::Primary => {
3517            let bg = v.selection.bg_fill;
3518            // 对比字色手算(0.33 的 Color32 无 luminance 口,0.34 才有):
3519            let c = bg.to_srgba_unmultiplied();
3520            let lum =
3521                (0.2126 * f64::from(c[0]) + 0.7152 * f64::from(c[1]) + 0.0722 * f64::from(c[2])) / 255.0;
3522            let fg = if lum > 0.55 {
3523                egui::Color32::BLACK
3524            } else {
3525                egui::Color32::WHITE
3526            };
3527            (bg, egui::Stroke::new(1.0, fg), fg)
3528        }
3529        OpTone::Ghost => (
3530            v.widgets.inactive.bg_fill,
3531            egui::Stroke::new(1.0, v.widgets.inactive.fg_stroke.color),
3532            v.widgets.inactive.fg_stroke.color,
3533        ),
3534        OpTone::Danger => (
3535            v.widgets.inactive.bg_fill,
3536            egui::Stroke::new(1.2, crate::tokens::warn(ui.ctx())),
3537            crate::tokens::warn(ui.ctx()),
3538        ),
3539        OpTone::Confirm => (
3540            crate::tokens::error(ui.ctx()).linear_multiply(0.22),
3541            egui::Stroke::new(1.2, crate::tokens::error(ui.ctx())),
3542            crate::tokens::error(ui.ctx()),
3543        ),
3544    };
3545    ui.add(
3546        egui::Button::new(egui::RichText::new(label).strong().color(fg))
3547            .min_size(egui::vec2(52.0, 26.0))
3548            .fill(fill)
3549            .stroke(stroke),
3550    )
3551}
3552
3553fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
3554    let accounts = shared.accounts();
3555    let locked = shared.accounts_locked();
3556    let md = shared.md_source();
3557    let snap = shared.trade_snap();
3558
3559    // 行显示名(别名优先)只用于「确认行」点名;按钮一律光杆(编辑/移除/连接,
3560    // 0.9.10 用户裁定:行即对象,钮不重复名字)。
3561    let row_label: std::collections::BTreeMap<String, String> = accounts
3562        .iter()
3563        .map(|a| {
3564            let d = a.display_name();
3565            let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
3566            (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
3567        })
3568        .collect();
3569
3570    // 工具条:行情源一览 + 计数 + 『添加账户』(主钮)。
3571    ui.horizontal(|ui| {
3572        ui.label(
3573            egui::RichText::new(format!(
3574                "当前行情源 = {}",
3575                if md.is_empty() {
3576                    "(未指定)".to_string()
3577                } else {
3578                    md.clone()
3579                }
3580            ))
3581            .strong(),
3582        );
3583        ui.label(format!("账户 {} 个", accounts.len()));
3584        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
3585            if shared.can_add_account()
3586                && op_button(ui, BTN_ACCOUNT_ADD, OpTone::Primary).clicked()
3587            {
3588                shared.begin_account_edit(None);
3589            }
3590        });
3591    });
3592
3593    const ACCOUNT_HEADERS: [&str; 9] = [
3594        "网关(派生名)",
3595        "别名",
3596        "账号",
3597        "td 前置",
3598        "状态",
3599        "阶段",
3600        "行情源",
3601        "收录",
3602        "操作",
3603    ];
3604    let mut acct_heads: Vec<(egui::Rect, String)> = Vec::new();
3605    let mut acct_rows: Vec<f32> = Vec::new();
3606    let acct_col_w = crate::ui_ext::grid_col_width(ui, "account_grid", 9);
3607    let acct_row_h = table_heights(ui).0;
3608    let band_x1 = ui.max_rect().max.x;
3609    egui::Grid::new("account_grid")
3610        .num_columns(9)
3611        .min_col_width(acct_col_w)
3612        .min_row_height(acct_row_h)
3613        .show(ui, |ui| {
3614            for h in &ACCOUNT_HEADERS {
3615                // 表头格 = **透明占位**(0.9.10 表头统一,用户裁定「表头字号颜色
3616                // 全桌面一套,参考主界面」):旧口径调用点画一遍 strong+small 正文色
3617                // 字,`header_band` 又按带的垂直中心压画一遍 text3 小字 = 双字叠加
3618                // → 看着与主界面表头不一样。字的唯一落笔点 = `header_band`
3619                // (Small/加粗/text3,与 pinned_table `header_cell_ui` 同源);
3620                // 这里的 label 只贡献**列定位矩形**。
3621                let r = ui.label(
3622                    egui::RichText::new(*h)
3623                        .strong()
3624                        .small()
3625                        .color(egui::Color32::TRANSPARENT),
3626                );
3627                acct_heads.push((r.rect, h.to_string()));
3628            }
3629            ui.end_row();
3630        // 空态 = 只有表头行(0.4.1 BD-UI-59 口径不变)。
3631        for a in &accounts {
3632            let row_y = ui.cursor().min.y;
3633            let row_x0 = ui.cursor().min.x;
3634            // 行带跳色(0.9.10 统一:与主界面 pinned_table 同一形制 —— 悬停
3635            // 整行 hovered.bg_fill,画在行首 = 整行图元之下,实色不遮字)。
3636            paint_row_band(ui, row_x0, row_y, band_x1);
3637            let is_md = a.gateway == md;
3638            // ── 网关(派生名)列:派生名单行;档案状态 = 二字徽标,说明进 tooltip。
3639            let badge = if a.restored {
3640                if a.password_ref {
3641                    ("档案", crate::tokens::muted(ui.ctx()), "档案行:以存档参数连接;密码走密库(值永不回显);改地址走『编辑』")
3642                } else {
3643                    ("缺密码", crate::tokens::error(ui.ctx()), "缺密码引用:连接会被柜台拒;『编辑』重输密码后『保存并连接』补录")
3644                }
3645            } else {
3646                ("", crate::tokens::muted(ui.ctx()), "")
3647            };
3648            ui.horizontal(|ui| {
3649                ui.label(egui::RichText::new(a.gateway.clone()).strong());
3650                if !badge.0.is_empty() {
3651                    let b = ui.add(
3652                        egui::Label::new(
3653                            egui::RichText::new(badge.0).color(badge.1).small(),
3654                        )
3655                        .sense(egui::Sense::hover()),
3656                    );
3657                    b.on_hover_text(badge.2);
3658                }
3659            });
3660            // ── 别名列(空 = —;寻址恒用派生名,规划 Q3)。
3661            ui.label(if a.alias.trim().is_empty() {
3662                "—".to_string()
3663            } else {
3664                a.alias.clone()
3665            });
3666            // ── 账号列。
3667            ui.label(a.account.clone());
3668            // ── td 前置列(0.9.10 用户裁定「配了什么就要显示」+ 表体字号统一):
3669            // 整串直显、正文号(旧 .small() = 与主界面表体字号不一致的病根之一),
3670            // Extend 防竖排;悬停给全串。
3671            let td = a.form.td_addr.trim();
3672            let tdresp = ui.add(
3673                egui::Label::new(
3674                    egui::RichText::new(if td.is_empty() { "—".to_string() } else { td.to_string() }),
3675                )
3676                .sense(egui::Sense::hover())
3677                .wrap_mode(egui::TextWrapMode::Extend),
3678            );
3679            tdresp.on_hover_text(format!(
3680                "td 前置:{td};要改地址端口 = 该行『编辑』→『保存并连接』"
3681            ));
3682            // ── 状态列(装配态 + 登录读数;告警带柜台短因,整句在事件日志)。
3683            match a.stage {
3684                crate::state::AccountStage::AssembleFailed => {
3685                    ui.add(
3686                        egui::Label::new(
3687                            egui::RichText::new(format!(
3688                                "装配失败:{}",
3689                                a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
3690                            ))
3691                            .color(crate::tokens::error(ui.ctx())),
3692                        )
3693                        .wrap_mode(egui::TextWrapMode::Extend),
3694                    );
3695                }
3696                _ => {
3697                    let color = match a.login {
3698                        crate::state::LoginState::Online => crate::tokens::ok(ui.ctx()),
3699                        crate::state::LoginState::LoginFailed
3700                        | crate::state::LoginState::FrontDown => {
3701                            crate::tokens::error(ui.ctx())
3702                        }
3703                        crate::state::LoginState::QueryStarved => crate::tokens::warn(ui.ctx()),
3704                        _ => crate::tokens::muted(ui.ctx()),
3705                    };
3706                    let text = match (a.login.is_alarm(), a.login_brief.as_ref()) {
3707                        (true, Some(brief)) => format!("{}·{}", a.status_label(), brief),
3708                        _ => a.status_label(),
3709                    };
3710                    ui.add(
3711                        egui::Label::new(egui::RichText::new(text).color(color))
3712                            .wrap_mode(egui::TextWrapMode::Extend),
3713                    );
3714                }
3715            }
3716            // ── 链列(网关推送的 `[链] ` 链状态;只显形不参与判据;正文号统一)。
3717            ui.add(
3718                egui::Label::new(
3719                    egui::RichText::new(a.chain_note.clone().unwrap_or_else(|| "—".to_string()))
3720                        .color(crate::tokens::muted(ui.ctx())),
3721                )
3722                .wrap_mode(egui::TextWrapMode::Extend),
3723            );
3724            // ── 行情源列(钮 tone 化统一:行内钮一律 op_button,小按钮退役)。
3725            ui.horizontal(|ui| {
3726                if is_md {
3727                    ui.label(egui::RichText::new("● 行情源").strong());
3728                }
3729                let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
3730                    op_button(
3731                        ui,
3732                        if is_md { "已是行情源" } else { "设为行情源" },
3733                        OpTone::Ghost,
3734                    )
3735                });
3736                if resp.inner.clicked() {
3737                    match shared.set_md_source(&a.gateway) {
3738                        Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
3739                        Err(e) => shared.log_line(format!("[账户] {e}")),
3740                    }
3741                }
3742            });
3743            // ── 收录列(只显勾选意图;网关回带实况在配置弹窗只读区,0.9.10 裁定)。
3744            ui.add(
3745                egui::Label::new(egui::RichText::new(classes_label(
3746                    &a.form.product_classes(),
3747                )))
3748                .wrap_mode(egui::TextWrapMode::Extend),
3749            );
3750            // ── 操作列(光杆钮 + 配色分层;行即对象,钮不带名字)。
3751            ui.horizontal(|ui| {
3752                let failed = a.stage == crate::state::AccountStage::AssembleFailed;
3753                let idle = a.stage == crate::state::AccountStage::NotAssembled;
3754                let stopped = a.login == crate::state::LoginState::Stopped;
3755                if failed || idle || stopped {
3756                    let label = if failed { "重试" } else { "连接" };
3757                    if op_button(ui, label, OpTone::Primary).clicked() {
3758                        if a.restored {
3759                            if stopped {
3760                                shared.push_cmd(UiCmd::Remove {
3761                                    name: a.gateway.clone(),
3762                                });
3763                                shared.push_cmd(UiCmd::Start {
3764                                    factory: "ctp".to_string(),
3765                                    name: a.gateway.clone(),
3766                                });
3767                                shared.log_line(format!(
3768                                    "[账户] {gateway} 重试:摘重起(存档直连;活跃委托时摘被拒会显形)",
3769                                    gateway = a.gateway
3770                                ));
3771                            } else {
3772                                shared.push_cmd(UiCmd::Start {
3773                                    factory: "ctp".to_string(),
3774                                    name: a.gateway.clone(),
3775                                });
3776                                shared.log_line(format!(
3777                                    "[账户] {gateway}:已按存档起连(svctl.start;密码走密库,不回显;参数以**存档段**为准 —— 改地址请『保存并连接』)",
3778                                    gateway = a.gateway
3779                                ));
3780                            }
3781                        } else {
3782                            push_connect(shared, &a.gateway, &a.form);
3783                        }
3784                    }
3785                }
3786                let open = snap
3787                    .orders
3788                    .iter()
3789                    .filter(|o| o.gateway_name == a.gateway && o.is_active())
3790                    .count();
3791                if a.stage == crate::state::AccountStage::Assembled
3792                    && a.login != crate::state::LoginState::Stopped
3793                    && open == 0
3794                    && op_button(ui, "断开", OpTone::Ghost).clicked()
3795                {
3796                    shared.push_cmd(UiCmd::Disconnect {
3797                        gateway: a.gateway.clone(),
3798                    });
3799                    shared.log_line(format!(
3800                        "[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
3801                        gateway = a.gateway
3802                    ));
3803                }
3804                let mut auto_on = a.auto_connect;
3805                let auto_resp = ui.checkbox(&mut auto_on, "自动连");
3806                if auto_resp.changed() {
3807                    match shared.set_auto_connect(&a.gateway, auto_on) {
3808                        Ok(()) => shared.log_line(format!(
3809                            "[账户] {} 自动连接 = {}(落盘请求已置:手点才写 ui 段)",
3810                            a.gateway,
3811                            if auto_on { "开" } else { "关" }
3812                        )),
3813                        Err(e) => shared.log_line(format!("[账户] {e}")),
3814                    }
3815                }
3816                if op_button(ui, "编辑", OpTone::Ghost).clicked() {
3817                    shared.begin_account_edit(Some(&a.gateway));
3818                }
3819                if open > 0 {
3820                    if op_button(ui, &format!("先撤 {open} 笔"), OpTone::Danger).clicked() {
3821                        for o in snap
3822                            .orders
3823                            .iter()
3824                            .filter(|o| o.gateway_name == a.gateway && o.is_active())
3825                        {
3826                            shared.push_cmd(UiCmd::CancelOrder {
3827                                orderid: o.orderid.clone(),
3828                                gateway: a.gateway.clone(),
3829                            });
3830                        }
3831                        shared.log_line(format!(
3832                            "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
3833                            a.gateway, open
3834                        ));
3835                    }
3836                } else {
3837                    let armed = shared.pending_confirm();
3838                    let armed_here = armed.as_deref() == Some(a.gateway.as_str());
3839                    let label = if armed_here { "确认移除?" } else { "移除" };
3840                    let tone = if armed_here { OpTone::Confirm } else { OpTone::Danger };
3841                    if op_button(ui, label, tone).clicked()
3842                        && shared.confirm_step(&a.gateway) == ConfirmStep::Fired
3843                    {
3844                        shared.push_cmd(UiCmd::Remove {
3845                            name: a.gateway.clone(),
3846                        });
3847                        shared.remove_account(&a.gateway);
3848                        shared.end_account_edit_if_editing(&a.gateway);
3849                        let has_pos = snap
3850                            .positions
3851                            .iter()
3852                            .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
3853                        if has_pos {
3854                            shared.log_line(format!(
3855                                "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
3856                                a.gateway
3857                            ));
3858                        }
3859                    }
3860                }
3861            });
3862            acct_rows.push(ui.min_rect().max.y);
3863            ui.end_row();
3864        }
3865    });
3866    let acct_band = header_band(ui, &acct_heads, ui.min_rect().max.x);
3867    grid_chrome(ui, acct_band, &acct_heads, &acct_rows);
3868
3869    // 二次确认提示(唯一保留的表下句:点了「移除」必须有这条,否则用户不知道在等什么)。
3870    if let Some(gw) = shared.pending_confirm() {
3871        let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
3872        ui.horizontal(|ui| {
3873            ui.label(
3874                egui::RichText::new(format!("移除确认中:再点该行的『确认移除』完成({shown})"))
3875                    .color(crate::tokens::warn(ui.ctx())),
3876            );
3877            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
3878                shared.confirm_cancel();
3879            }
3880        });
3881    }
3882
3883    // 元账号 md 前置为空 = 行情永不出数(业务告警,只在真为空时出现一行)。
3884    if let Some(src) = accounts.iter().find(|a| a.gateway == md)
3885        && src.form.md_addr.trim().is_empty()
3886    {
3887        ui.label(
3888            egui::RichText::new(format!(
3889                "元账号 {} 的 md 前置为空:点该行『编辑』补填 md 前置,否则行情不会出数",
3890                src.gateway
3891            ))
3892            .color(crate::tokens::warn(ui.ctx())),
3893        );
3894    }
3895}
3896
3897/// 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):『添加账户』与
3898/// 『编辑 <账户>』开**同一个弹窗的两种模式**;非 modal(不阻塞主窗与行情观察)。
3899/// 账号/前置全部界面手工输入,插件不读文件;密码位掩码;**无订阅字段** ——
3900/// 用户裁定:订阅走下单面板代码回车或查询合约窗勾选。
3901/// 关闭(『取消』或窗 ×)= **放弃未保存**(规划 Q24);被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
3902/// 账户表单的标签列(0.4.1 九期 BD-UI-62,用户裁定「这几个字段全部放成一列,数值也放成一列,
3903/// 每一个字段一行」):标签占固定宽,数值列因此对齐成一列。
3904///
3905/// 为什么不用 `Grid`:0.2.17 实锤(s39 同一段)—— 表单 Grid 的列宽在测量期被「窗宽 / 列数」
3906/// 夹死(实测每格 ≈120px),`desired_width` 永不生效,一根真实前置 `tcp://182.254.242.81:30001`
3907/// 只显示出 "tcp:/"。固定宽的标签格 + 同一行剩下的宽度给数值,两头都不夹。
3908const ACCT_FORM_LABEL_W: f32 = 86.0;
3909
3910fn acct_form_label(ui: &mut egui::Ui, text: &str) {
3911    ui.scope_builder(
3912        egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
3913            ui.cursor().min,
3914            egui::vec2(
3915                ACCT_FORM_LABEL_W,
3916                ui.text_style_height(&egui::TextStyle::Body),
3917            ),
3918        )),
3919        |ui| {
3920            ui.label(text);
3921        },
3922    );
3923}
3924
3925/// 0.9.10 P2c(用户裁定「账户配置界面也要美化」):弹窗表单行 = **行带跳色**,
3926/// 与主界面 pinned_table / 账户面板同一形制(形制与两条纪律的注释见
3927/// `paint_row_band`:行首注册实色带 + probe 面不画)。
3928fn acct_form_row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
3929    let row_y = ui.cursor().min.y;
3930    let x0 = ui.cursor().min.x;
3931    let band = egui::Rect::from_min_max(
3932        egui::pos2(x0, row_y),
3933        egui::pos2(ui.max_rect().max.x, row_y + table_heights(ui).0),
3934    );
3935    row_band_paint(ui, band);
3936    ui.horizontal(|ui| {
3937        add(ui);
3938    });
3939}
3940
3941fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
3942    let editing: Option<crate::state::AccountRow> =
3943        shared.editing_account().and_then(|g| shared.account(&g));
3944    let editing_on = editing.is_some();
3945    let md = shared.md_source();
3946    let locked = shared.accounts_locked();
3947    let mut form: crate::state::CtpForm = shared.ctp_form();
3948    let mut alias = shared.edit_alias();
3949    // 被拦原因上屏(形制校验 / 清册守卫 / 装配被拒):弹窗不关(规划 U3 / Q26)。
3950    if let Some(e) = shared.edit_error() {
3951        ui.label(egui::RichText::new(format!("拦下:{e}")).color(crate::tokens::error(ui.ctx())));
3952    }
3953    // 0.2.17 布局口径:表单用 horizontal 行不用 Grid —— Grid 列宽在测量期被
3954    // 「窗宽 / 列数」夹死(实测每格 ≈120px),`desired_width` 永不生效,
3955    // 一根真实前置 `tcp://182.254.242.81:30001`(30 字符)只显示出 "tcp:/"。
3956    // 身份行:别名(仅显示用,不参与寻址,规划 Q3)+ broker + td 前置 + td 用户。
3957    // 编辑模式 broker / td 用户**只读**(规划 Q25:它们决定派生注册名,就地改 = 孤儿实例 + 账本错配)。
3958    // 0.4.1 九期 BD-UI-62:一字段一行(原来四个字段挤在一行,窄窗里数值列互相挤)。
3959    acct_form_row(ui, |ui| {
3960        acct_form_label(ui, "别名");
3961        if ui
3962            .add(
3963                egui::TextEdit::singleline(&mut alias)
3964                    .hint_text("显示用,可空")
3965                    .desired_width(210.0),
3966            )
3967            .changed()
3968        {
3969            shared.set_edit_alias(alias.clone());
3970        }
3971    });
3972    acct_form_row(ui, |ui| {
3973        acct_form_label(ui, "broker");
3974        ui.add_enabled_ui(!editing_on, |ui| {
3975            ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
3976        });
3977    });
3978    acct_form_row(ui, |ui| {
3979        acct_form_label(ui, "td 前置");
3980        ctp_edit(
3981            ui,
3982            shared,
3983            &mut form.td_addr,
3984            "tcp://host:40001",
3985            CtpField::TdAddr,
3986        );
3987    });
3988    acct_form_row(ui, |ui| {
3989        acct_form_label(ui, "td 用户");
3990        ui.add_enabled_ui(!editing_on, |ui| {
3991            ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
3992        });
3993    });
3994    if let Some(r) = &editing {
3995        // 只读口径说明(规划 Q25):派生注册名由 broker + 账号决定,就地改会留下
3996        // 跑着旧名的孤儿实例(svctl 侧无改名口),账本里的归属也会错配。
3997        ui.label(
3998            egui::RichText::new(format!(
3999                "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
4000                r.gateway
4001            ))
4002            .color(crate::tokens::warn(ui.ctx())),
4003        );
4004        // 0.9.0 档案行提示(BD-APP-1;密码不回显口径):存档密码在密库,表单草稿
4005        // 被清成空 —— 界面永远显示不了密码,这不是 bug。
4006        // 分支:
4007        // - 引用在位:只改地址/别名/勾选 → 密码留空点『保存』(不碰档案);
4008        //   『连接』走存档仍用密库密码。「保存并连接」要先重输密码(网关拒收空密码)。
4009        // - 引用不在位:该档当初没存密码(或密库被清过)→ 连接必然因缺密码被拒,
4010        //   重输密码后『保存并连接』受理即补录档案。
4011        let hint = if r.password_ref {
4012            "档案行:密码在密库(**不回显**)。只改地址/勾选 → 密码留空『保存』即可;『保存并连接』要重输密码"
4013        } else {
4014            "档案行:**缺密码引用**(该档没存密码/密库被清)——重输密码后『保存并连接』补录档案,否则连接会被拒"
4015        };
4016        ui.label(
4017            egui::RichText::new(hint)
4018                .color(crate::tokens::muted(ui.ctx()))
4019                .small(),
4020        );
4021    }
4022    acct_form_row(ui, |ui| {
4023        acct_form_label(ui, "td 密码");
4024        ctp_edit(
4025            ui,
4026            shared,
4027            &mut form.td_password,
4028            "密码",
4029            CtpField::TdPassword,
4030        );
4031    });
4032    acct_form_row(ui, |ui| {
4033        acct_form_label(ui, "AppID");
4034        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
4035    });
4036    acct_form_row(ui, |ui| {
4037        acct_form_label(ui, "AuthCode");
4038        ctp_edit(
4039            ui,
4040            shared,
4041            &mut form.td_auth_code,
4042            "授权码",
4043            CtpField::AuthCode,
4044        );
4045    });
4046    // 单源纪律(规划 Q12):编辑模式下「非行情源账户 + 行情源已锁定」→ md 位置灰。
4047    // 它当不了源,填了也会在装配侧被清空;要换源须先移除在场账户(规划 D3)。
4048    let md_editable = match &editing {
4049        None => true,
4050        Some(r) => !locked || r.gateway == md,
4051    };
4052    // md 账号 / 密码**不再单列**(0.4.1 九期 BD-UI-62,用户裁定「MD 的那个账号和密码不要单独列了,
4053    // 默认的就是不用登录嘛,我们就不用填」)。口径与 Python 一致:Python 侧 CTP 配置只有
4054    // 一套账号/密码(`trader/utility.py:339` 一带,`appID` 那一格),行情会话复用同一套凭据 ——
4055    // 全仓 grep 不到任何 `md_user` / `md_password`。我们的 `CtpForm.md_user/md_password` 因此
4056    // 退回成「装配侧内部字段」:表单不给填,留空 = 回落 td 凭据(与 Python 同一行为)。
4057    acct_form_row(ui, |ui| {
4058        acct_form_label(ui, "md 前置");
4059        ui.add_enabled_ui(md_editable, |ui| {
4060            ctp_edit(
4061                ui,
4062                shared,
4063                &mut form.md_addr,
4064                "tcp://host:40011",
4065                CtpField::MdAddr,
4066            );
4067        });
4068    });
4069    if !md_editable {
4070        ui.horizontal(|ui| {
4071            acct_form_label(ui, "");
4072            ui.label(
4073                egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
4074                    .color(crate::tokens::warn(ui.ctx())),
4075            );
4076        });
4077    }
4078    // 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。勾选变更立刻回写共享表单
4079    // —— 与 ctp_edit 同一口径,否则下一帧从 shared 重读就把这次点击丢了。
4080    // 尾句收进 tooltip(0.9.10 用户裁定:说明不堆行里,行带里一眼看得清控件)。
4081    acct_form_row(ui, |ui| {
4082        ui.label("收录勾选:");
4083        let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
4084        let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
4085        if fut || opt {
4086            shared.set_ctp_form(form.clone());
4087        }
4088        let hint = ui.label(
4089            egui::RichText::new("勾选意图")
4090                .color(crate::tokens::muted(ui.ctx()))
4091                .small(),
4092        );
4093        hint.on_hover_text(
4094            "都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)",
4095        );
4096    });
4097    // F4 口径(0.2.20,与账户面板底部同一句 —— 共用 `TXT_PRODUCT_CLASS_SCOPE` 常量)。
4098    // 公用合约表是在场各网关的并集:从账户改了勾选、行情源账户没改,表就不会多一行期权。
4099    ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).weak());
4100    // 只读回带区(规划 U9):勾选是**意图**,网关回带才是**事实** ——
4101    // 旧口径 `ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
4102    if let Some(r) = &editing {
4103        acct_form_row(ui, |ui| {
4104            ui.label("网关实际收录(只读回带):");
4105            ui.label(if r.effective_classes.is_empty() {
4106                "—(尚未装配或无回带)".to_string()
4107            } else {
4108                classes_label(&r.effective_classes)
4109            });
4110            ui.label("装配/登录状态(只读):");
4111            ui.label(format!(
4112                "{}{}",
4113                r.status_label(),
4114                r.fail_note
4115                    .as_ref()
4116                    .map(|n| format!(" —— {n}"))
4117                    .unwrap_or_default()
4118            ));
4119        });
4120    }
4121    // 操作行(规划 §3.3 四态):『保存』只写清册;『保存并连接』= 保存 + 入列一条 Connect 令;
4122    // 『取消』= 放弃未保存(与窗 × 同口径,规划 Q24)。被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
4123    if shared.account_edit_dirty() {
4124        ui.label(
4125            egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
4126                .color(crate::tokens::warn(ui.ctx())),
4127        );
4128    }
4129    // 0.9.10 P2c(四面美化):tone 化 = 主行动(保存/保存并连接)Primary 实底,
4130    // 放弃路(取消)Ghost;说明从按钮行摘出(按钮行只放按钮,用户裁定口径)。
4131    ui.horizontal(|ui| {
4132        if op_button(ui, "保存", OpTone::Primary).clicked() {
4133            match save_account_draft(shared, &form, &alias, false) {
4134                Ok(()) => shared.end_account_edit(),
4135                Err(e) => {
4136                    shared.log_line(format!("[账户] 保存被拦:{e}"));
4137                    shared.set_edit_error(Some(e));
4138                }
4139            }
4140        }
4141        if op_button(ui, "保存并连接", OpTone::Primary).clicked() {
4142            match save_account_draft(shared, &form, &alias, true) {
4143                Ok(()) => shared.end_account_edit(),
4144                Err(e) => {
4145                    shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
4146                    shared.set_edit_error(Some(e));
4147                }
4148            }
4149        }
4150        if op_button(ui, "取消", OpTone::Ghost).clicked() {
4151            shared.end_account_edit();
4152        }
4153    });
4154    ui.label(
4155        egui::RichText::new("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)")
4156            .weak()
4157            .small(),
4158    );
4159}
4160
4161/// 账户清册里的派生名集合(账本标注用,每表算一次,不在每格里克隆清册)。
4162fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
4163    shared
4164        .accounts()
4165        .iter()
4166        .map(|a| a.gateway.clone())
4167        .collect()
4168}
4169
4170/// 账本行的网关标注(0.2.18 Commit B,规划 U8):该网关已不在账户清册 = **已摘除账户的残留行**。
4171///
4172/// 摘除不清账本(柜台侧不受影响,重加该账户后由轮转查询重建视图 —— F5 实连复测:
4173/// 摘除后账本 81/29/44/2 原封不动),但界面必须说清这一行属于谁:旧口径零标注,
4174/// 用户会把已摘除账户的委托当成在场账户的。
4175/// 口径边界:**清册为空 = 不标注**(没有账户配置面时谈不上「摘除」,纯账本巡回钉 s27 即此形);
4176/// 资金格不标注(那里的网关位是账户来源标签,`ctp` 这类历史字面量不在清册口径内 —— 刻意划界)。
4177fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
4178    if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
4179        gateway.to_string()
4180    } else {
4181        format!("{gateway}(已摘除)")
4182    }
4183}
4184
4185/// 产品类短标签(`"1"` → 期货,`"2"` → 期权):勾选意图与网关回带事实并排显示用。
4186fn classes_label(classes: &[String]) -> String {
4187    if classes.is_empty() {
4188        return "—".to_string();
4189    }
4190    classes
4191        .iter()
4192        .map(|c| match c.as_str() {
4193            "1" => "期货",
4194            "2" => "期权",
4195            other => other,
4196        })
4197        .collect::<Vec<_>>()
4198        .join("+")
4199}
4200
4201/// 『连接』入口的唯一出令处(四态分离:一条令做完 `svctl.start` + `{gw}.configure`)。
4202///
4203/// 入列同时就地置「装配中」:插件出队时也会置(幂等),但**入列到出队之间**若不置,
4204/// 行上的『连接/重试』钮还亮着 = 可重复入列(规划 Q7 防重入的界面侧一半)。
4205/// 0.9.10:桌面相自动连接(清点勾选项)也走这一口 = 手点/自动**同一出令处**。
4206pub(crate) fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
4207    shared.push_cmd(UiCmd::Connect {
4208        gateway: gateway.to_string(),
4209        form: form.clone(),
4210    });
4211    shared.note_connect_sent(gateway);
4212    shared.log_line(format!(
4213        "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
4214        gateway
4215    ));
4216}
4217
4218/// 配置弹窗『保存』/『保存并连接』的共同路径:**校验 → 写清册 →(可选)入列一条 Connect 令**。
4219///
4220/// - 编辑模式:覆盖该账户留档 + 别名(broker/账号只读 → 派生名不变,规划 Q25)。
4221/// - 添加模式:派生建行(别名随行入册,唯一性在 `add_account` 守卫里)+ 单源定源(规划 §5.C / D3)。
4222///
4223/// 被拦返回原因字符串:调用方**不关弹窗**,把原因写进 `edit_error` 显形(规划 Q26)。
4224fn save_account_draft(
4225    shared: &UiShared,
4226    form: &crate::state::CtpForm,
4227    alias: &str,
4228    connect: bool,
4229) -> Result<(), String> {
4230    form.validate()?;
4231    if let Some(gw) = shared.editing_account() {
4232        // 编辑目标必须还在清册(弹窗非 modal:期间该行可能被摘除)。
4233        if shared.account(&gw).is_none() {
4234            return Err(format!(
4235                "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
4236            ));
4237        }
4238        shared.update_account_form(&gw, form.clone());
4239        if !shared.set_account_alias(&gw, alias) {
4240            return Err(format!(
4241                "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
4242            ));
4243        }
4244        shared.log_line(format!(
4245            "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
4246        ));
4247        if connect {
4248            push_connect(shared, &gw, form);
4249        }
4250        return Ok(());
4251    }
4252    // 多前置派生(0.9.10):在场清册 = taken 清单,同 broker+账号 换 td 前置
4253    // = 自动后缀 `_2`(三处全同 = 拒,弹窗显形)。
4254    let taken: Vec<(String, String, String, String)> = shared
4255        .accounts()
4256        .iter()
4257        .map(|a| {
4258            (
4259                a.gateway.clone(),
4260                a.broker_id.clone(),
4261                a.account.clone(),
4262                a.form.td_addr.clone(),
4263            )
4264        })
4265        .collect();
4266    let mut row = crate::state::AccountRow::derive_with(form, &taken)?;
4267    row.alias = alias.trim().to_string();
4268    let gateway = row.gateway.clone();
4269    let account = row.account.clone();
4270    shared.add_account(row.clone())?;
4271    // 单源纪律在「保存」这一刻定源(规划 §5.C / D3):无源且无账户在场 → 新账户就是元账号
4272    // (md 前置保留 —— 清掉它等于这个账户自己也永远收不到 tick);
4273    // 已有源或已有账户在场 → 按纯 TD 装配(md 位清空,**留档与下发同一份**,消 U4)。
4274    let md = shared.md_source();
4275    let mut f = row.form.clone();
4276    if md.is_empty() && !shared.accounts_locked() {
4277        match shared.set_md_source(&gateway) {
4278            Ok(()) => {
4279                if f.md_addr.trim().is_empty() {
4280                    shared.log_line(format!(
4281                        "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
4282                    ));
4283                } else {
4284                    shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
4285                }
4286            }
4287            Err(e) => {
4288                shared.log_line(format!("[账户] {e}"));
4289                f.md_addr.clear();
4290                f.md_user.clear();
4291                f.md_password.clear();
4292            }
4293        }
4294    } else {
4295        f.md_addr.clear();
4296        f.md_user.clear();
4297        f.md_password.clear();
4298        if md.is_empty() {
4299            shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
4300        }
4301    }
4302    shared.update_account_form(&gateway, f.clone());
4303    // **四态分离:『保存』到此为止** —— 不 start、不 configure(规划裁定)。
4304    shared.log_line(format!(
4305        "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
4306    ));
4307    if connect {
4308        push_connect(shared, &gateway, &f);
4309    }
4310    Ok(())
4311}
4312
4313/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
4314/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
4315/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
4316fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
4317    let mut q = shared.contract_query();
4318    ui.horizontal(|ui| {
4319        ui.label("代码或交易所(留空 = 全部):");
4320        if ui
4321            .add(
4322                egui::TextEdit::singleline(&mut q)
4323                    .hint_text("cu 或 SHFE")
4324                    .desired_width(200.0),
4325            )
4326            .changed()
4327        {
4328            shared.set_contract_query(q.clone());
4329        }
4330    });
4331    let rows = shared.contracts(&q);
4332    // 表头单源(0.2.6 审计修:空态分支曾误写另一套表头「代码/…/最后价」,
4333    // 与非空分支不一致——列语义两处必须同源)。
4334    // 六列(2026-10-28 用户裁定:①「选(代码)」列删 —— 勾选改行点(行内任一格
4335    // 单击 = 翻转该行的订阅勾选,原勾选框的两个动作合一在行级);②接口列不做
4336    // (用户裁定);③补「合约分类」「最小委托量」两列 —— ContractData 里
4337    // asset / min_limit_order_volume 都在,纯显示层,对齐 python ContractManager 列集)。
4338    const CONTRACT_HEADERS: [&str; 6] = [
4339        "交易所",
4340        "名称",
4341        "合约分类",
4342        "乘数",
4343        "最小变动",
4344        "最小委托量",
4345    ];
4346    // 空态不再分支、不再画提示行(0.4.1 六期 BD-UI-59,同未成交口径)。
4347    // 0.2.29 那条审计修仍然成立:空态与非空态必须走同一条路(同一 id_salt、同一列语义),
4348    // 原来空态分支走无列线的入口、非空走有列线的入口,同一张表在空/非空之间切换时形制会变。
4349    let selected = shared.selected_contracts();
4350    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
4351    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
4352    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
4353    // 行溢出才出滚动条。
4354    ui.horizontal(|ui| {
4355        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
4356        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
4357            if selected.is_empty() {
4358                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
4359            } else {
4360                // en_symbol 勾选集 → CTP 裸代码订阅(ask {gw}.subscribe 收裸代码)
4361                // 同上一条:归一化只有网关那一份实现;归不了的点名,不静默下发。
4362                let dropped: Vec<&String> = selected
4363                    .iter()
4364                    .filter(|k| ek_ctp::map::instrument_id(k).is_none())
4365                    .collect();
4366                if !dropped.is_empty() {
4367                    shared.log_line(format!("[查询合约] 代码无法归一(交易所前缀未知):{:?}", dropped));
4368                }
4369                let symbols: Vec<String> = selected
4370                    .iter()
4371                    .filter_map(|k| ek_ctp::map::instrument_id(k))
4372                    .collect();
4373                match subscribe_target(shared) {
4374                    Ok(gw) => {
4375                        shared.push_cmd(UiCmd::CtpSubscribe {
4376                            gateway: gw.clone(),
4377                            symbols,
4378                        });
4379                        shared.log_line(format!(
4380                            "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
4381                            selected.len(),
4382                            gw
4383                        ));
4384                    }
4385                    Err(e) => shared.log_line(format!("[订阅] {e}")),
4386                }
4387            }
4388        }
4389    });
4390    // 网格线:这张表曾经是「全仓唯一开网格线的表」(Python 侧只有合约表没调
4391    // `setShowGrid(False)`:widget.py:2423 / 2621 建表时没关,交易表在 367 关了)。
4392    // 0.4.1 五期 BD-UI-58 按用户裁定反过来:网格线是**全部表**的固定形制,这条 Python
4393    // 差异从此不再成立(记死在 CHANGELOG / DESIGN / README)。
4394    pinned_table(ui, "contract_grid", &CONTRACT_HEADERS, |body, row_h| {
4395        for c in &rows {
4396            let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
4397            // 0.8.5:原「勾选钮标签 = en_symbol」的勾选框列已撤;勾选身份 = en_symbol,
4398            // 由订阅钮吃勾选集;行点击 = 翻转(见 cell! 宏)。
4399            // BD-UI-31 扩展(2026-10-28 收口):行选中即勾选 —— 原勾选框列删除,
4400            // 「动作归行」升级:行内任一格单击 = 翻转订阅勾选 + 记行选。
4401            // 「已选 N 项 / 订阅」钮吃同一份勾选存储,行为不变。
4402            let row_sel = shared.row_selected("contracts", &key);
4403            // key 是行内局部:格延迟画笔要活到 show,必须自带所有权 ——
4404            // 在 rep_row 闭包外准备 key 克隆,各格自己再取。
4405            let key_owned = key.clone();
4406            body.row(row_h, row_sel, |r| {
4407                macro_rules! cell {
4408                    ($make:expr, $col:expr) => {{
4409                        let key = key_owned.clone();
4410                        r.cell(move |ui, ctx| {
4411                            let (cell_r, inner_r) = pin_cell(ui, ctx, $make);
4412                            let hit =
4413                                cell_r.clicked() || inner_r.as_ref().is_some_and(|r| r.clicked());
4414                            if hit {
4415                                shared.toggle_contract_selection(&key.clone());
4416                                shared.select_row("contracts", &key.clone());
4417                            }
4418                        });
4419                    }};
4420                }
4421                // 2026-10-28 用户裁定:「选(代码)」勾选框列删。订阅勾选改在行级:
4422                // 行内任一格单击 = 翻转该行的勾选(原「勾选 = 订阅选择 + 行选中」
4423                // 两动作合一在行上),「已选 N 项」与「订阅勾选集」钮照吃同一存储。
4424                cell!(|ui| ui.label(c.exchange.as_str().to_string()), 0);
4425                cell!(|ui| ui.label(c.name.clone()), 1);
4426                cell!(|ui| ui.label(c.asset.as_str().to_string()), 2);
4427                cell!(
4428                    |ui| ui.label(crate::tokens::num_text(crate::format::volume(c.size))),
4429                    3
4430                );
4431                cell!(
4432                    |ui| ui.label(crate::tokens::num_text(crate::format::price_tick(
4433                        c.price_tick
4434                    ))),
4435                    4
4436                );
4437                cell!(
4438                    |ui| ui.label(
4439                        c.min_limit_order_volume
4440                            .map(|v| crate::tokens::num_text(crate::format::volume(v)))
4441                            .unwrap_or_else(|| "-".to_string().into())
4442                    ),
4443                    5
4444                );
4445            });
4446        }
4447    });
4448}
4449
4450/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
4451enum CtpField {
4452    Broker,
4453    TdAddr,
4454    TdUser,
4455    TdPassword,
4456    AppId,
4457    AuthCode,
4458    MdAddr,
4459}
4460
4461/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
4462fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
4463    let is_secret = matches!(field, CtpField::TdPassword | CtpField::AuthCode);
4464    let mut w = egui::TextEdit::singleline(val)
4465        .hint_text(hint)
4466        // 0.2.17:输入位加宽(旧 110 装不下一根 CTP 前置 `tcp://182.254.242.81:30001`
4467        // = 30 字符,用户看不见自己填的前置,也无法核对端口;快照钉 s39 前后对照)。
4468        .desired_width(210.0);
4469    if is_secret {
4470        w = w.password(true);
4471    }
4472    if ui.add(w).changed() {
4473        let mut f = shared.ctp_form();
4474        match field {
4475            CtpField::Broker => f.broker_id = val.clone(),
4476            CtpField::TdAddr => f.td_addr = val.clone(),
4477            CtpField::TdUser => f.td_user = val.clone(),
4478            CtpField::TdPassword => f.td_password = val.clone(),
4479            CtpField::AppId => f.td_app_id = val.clone(),
4480            CtpField::AuthCode => f.td_auth_code = val.clone(),
4481            CtpField::MdAddr => f.md_addr = val.clone(),
4482        }
4483        shared.set_ctp_form(f);
4484    }
4485}
4486
4487/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
4488///
4489/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
4490fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
4491    let Some(c) = cell else {
4492        // F1 键统一(0.9.9):空窗必须说"补数在途 + 没来的根因去向",
4493        // 不许装"等 dm 合成"的静默空图(开窗即入队 tq 历史补数)。
4494        ui.label("(K线无格:tq 历史补数在途;若长期不画,多半是 tq 桥/网关未上线)");
4495        return;
4496    };
4497    if c.bars.is_empty() {
4498        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
4499        return;
4500    }
4501    // 柱时间按北京钟面给图:egui_charts 0.2 的 Bar 只吃 DateTime<Utc> 且按
4502    // UTC 钟面画刻度 —— 用「换到 +08 再标回 UTC」的重标定,让横轴刻度显示
4503    // 北京钟面(数据面存 UTC 纳秒;重标定只挪刻度,不挪序)。
4504    let mut data = egui_charts::model::bar::BarData::new();
4505    for b in &c.bars {
4506        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
4507            .with_timezone(&beijing_tz())
4508            .with_timezone(&chrono::Utc);
4509        data.push(egui_charts::model::bar::Bar::new(
4510            dt,
4511            b.open_price,
4512            b.high_price,
4513            b.low_price,
4514            b.close_price,
4515            b.volume,
4516        ));
4517    }
4518    ui.label(format!(
4519        "K线 {}@{}s({} 根)",
4520        c.symbol,
4521        c.interval,
4522        c.bars.len()
4523    ));
4524    // 0.2.30(BD-UI-46):图表色板不再自带它那套 TradingView 令牌。
4525    // egui_charts 0.2 的 `ChartConfig::default()` 读的是它自己的 RON `DESIGN_TOKENS`
4526    // (bullish `#26a69a` 青 / bearish `#F23645` 红)——**涨跌方向正好和我们的
4527    // 红涨绿跌相反**,而且它**不读 egui `Visuals`**,界面换什么档都影响不到它。
4528    // 所以这里显式覆盖五支:涨 = RISE(红)、跌 = FALL(绿)、底 = 当前档 `bg1`、
4529    // 网格 = 当前档 `border`、文字 = 当前档 `text1`。
4530    // 0.8.0 双档补的正是这后三支(判据 `s93`):旧口径直写 `shadcn::BG1/BORDER/TEXT1`
4531    // = 深档常量,浅色档下 K 线会是界面里唯一一块黑底 —— 这一处必须跟着档走。
4532    let pal = crate::tokens::palette(ui.ctx());
4533    let cfg = egui_charts::config::ChartConfig {
4534        bullish_color: crate::tokens::rise(ui.ctx()),
4535        bearish_color: crate::tokens::fall(ui.ctx()),
4536        background_color: pal.bg1,
4537        grid_color: pal.border,
4538        text_color: pal.text1,
4539        ..egui_charts::config::ChartConfig::default()
4540    };
4541    let mut chart = egui_charts::widget::Chart::with_config(data, cfg).visible_bars(60);
4542    chart.set_symbol(&c.symbol);
4543    chart.set_timeframe_label(&format!("{}s", c.interval));
4544    chart.enable_tracking_mode();
4545    let _ = chart.show(ui);
4546}
4547
4548#[cfg(test)]
4549mod tests {
4550    use super::*;
4551
4552    // 表头带**派生规则**钉(BD-UI-41,0.2.30 收缩成一套主题):
4553    // Chrome 的每个角色必须取到令牌里那一档,不许自己挑。
4554    // (色板原值本身由 `tests/ui_kittest.rs` 的 `u1_palette_matches_source` 钉 ——
4555    //  色值字面量只留在测试侧,src 侧不写 `from_rgb`。)
4556    // 原来这里还有一条「不得与金融深色那一列混同」:金融深色已随主题面删除,
4557    // 判据对象不存在,删掉,不留一条空转的断言。
4558    // 0.4.0:tab 那三档派生判据随 `tab_label` 一起撤除 —— tab 形态归 dock,
4559    // 其样式来自同一份 `visuals`(v5 钉住 `Style::from_egui` 这条派生路径)。
4560    #[test]
4561    fn chrome_derives_from_token_palette() {
4562        // 0.8.0 双档:派生规则对**两档各钉一遍**(浅档漏一支 = u9 钉的事,这里钉的是
4563        // 「表头文字必须走次级色」这条新口径在两档都成立)。
4564        for theme in [crate::tokens::Theme::Dark, crate::tokens::Theme::Light] {
4565            let v = crate::tokens::visuals_for(theme);
4566            let ch = Chrome::of(&v, theme);
4567            let p = theme.palette();
4568            // 0.4.1 四期(BD-UI-57):旧口径 `line` 一支兼做「带底 + 描边」(qss:1959-1970
4569            // `QHeaderView::section { background #455364 }` 与描边同色)。Nova 把两者拆开 →
4570            // 两条断言各钉一支,谁也不许兼谁的活。
4571            assert_eq!(ch.line, p.border, "{theme:?} 档的描边必须取 border 那支");
4572            assert_eq!(ch.band_fill, p.bg4, "{theme:?} 档的表头带底必须取 BG4 那支");
4573            // 0.8.0 裁定 C:表头文字走次级色(参考站 `th{color:ink-muted}`),
4574            // 与表体(`text2`)/ 标题(`text1`)是三层,不许塌回一层。
4575            assert_eq!(
4576                ch.header_text, p.text3,
4577                "{theme:?} 档的表头文字必须走次级色"
4578            );
4579            assert_ne!(
4580                ch.header_text, p.text1,
4581                "{theme:?} 档的表头文字与正文同色 = 层级塌一档"
4582            );
4583        }
4584    }
4585
4586    // 日志入环打戳钉(BD-UI-41):日志表格化后「时间」列的事实源是 `LogRing`,
4587    // 打戳必须与表内时间列(`fmt_dt`)同一个口径 = 北京 +08,不能跟本机时区走
4588    // (本机不是 +08 时,口径错 = 日志与成交表差几小时,交易员对不上账)。
4589    #[test]
4590    fn log_entries_stamp_beijing_wall_clock() {
4591        let mut ring = crate::state::LogRing::new(4);
4592        ring.push("[ops] 一行");
4593        let e = &ring.entries()[0];
4594        assert_eq!(e.text, "[ops] 一行", "正文不得被时间戳污染");
4595        assert_eq!(e.at.len(), 8, "时间戳必须是 HH:MM:SS(8 字符),实测 {}", e.at);
4596        for seg in e.at.split(':') {
4597            assert!(seg.parse::<u32>().is_ok(), "时间戳有非数字段:{seg}");
4598        }
4599        let want = chrono::Utc::now()
4600            .with_timezone(&beijing_tz())
4601            .format("%H:%M:%S")
4602            .to_string();
4603        let secs = |s: &str| -> i64 {
4604            s.split(':')
4605                .map(|x| x.parse::<i64>().unwrap())
4606                .fold(0, |acc, v| acc * 60 + v)
4607        };
4608        let (a, b) = (secs(&e.at), secs(&want));
4609        let d = (a - b).abs();
4610        // 允许跨秒抖动,并容忍跨午夜(差值接近 86400 即同一口径的另一侧)。
4611        assert!(
4612            d <= 2 || d > 86_398,
4613            "日志时间戳必须走北京 +08:实测 {a} vs 期望 {b}"
4614        );
4615        // 旧门面不变:`snapshot()` 仍只给正文(测试与巡回取证靠它)。
4616        assert_eq!(ring.snapshot(), vec!["[ops] 一行".to_string()]);
4617    }
4618
4619    // 表内时间列北京钟面钉(0.2.13):数据面存 UTC 纳秒,展示换回 +08 ——
4620    // 北京 09:30:01 入账后是 UTC 01:30:01,fmt_dt 必须显示 09:30:01,
4621    // 不是 01:30:01(旧实现的 UTC 直显,交易员看差 8 小时)。
4622    #[test]
4623    fn fmt_dt_shows_beijing_wall_clock() {
4624        let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
4625            ek_data::time::civil_to_nanos(ek_data::time::Civil {
4626                year: 2026,
4627                month: 4,
4628                day: 16,
4629                hour: 1,
4630                minute: 30,
4631                second: 1,
4632                nanos: 0,
4633            }),
4634        );
4635        // 真值自洽:UTC 01:30:01 ↔ 北京 09:30:01(双向换算回原钟面)
4636        let beijing = expect.with_timezone(&beijing_tz());
4637        assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
4638        // 再来一个跨日钉:北京 00:00:30 = UTC 前日 16:00:30(同 UTC 日,
4639        // 不同北京日 —— 旧实现会把这根显示成 16:00:30)
4640        let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
4641            year: 2026,
4642            month: 4,
4643            day: 15,
4644            hour: 16,
4645            minute: 0,
4646            second: 30,
4647            nanos: 0,
4648        });
4649        // 0.3.1(Wave D):格式从 `HH:MM:SS` 收成 `HH:MM:SS.mmm`(照 Python `TimeCell`),
4650        // 时区口径没动 —— 这条钉的仍是「北京墙钟」这件事,不是小数位。
4651        assert_eq!(crate::format::time_hms_millis(Some(ns)), "00:00:30.000");
4652        // None → 占位
4653        assert_eq!(crate::format::time_hms_millis(None), "-");
4654    }
4655}