# DESIGN —— ek-ui(S1b 常驻观测界面)
Frozen ref: docs/ek-ui-station-plan.md(DV 表 + S1b 段验收)。本档只记**结构决定**与
执行期钉。形式沿用 ek-dm/ek-station 的 BD-U1.. 编号加序。
## 结构
- `state.rs`:`UiShared`(总线面写 / 窗口面读)+ `LogRing`(cap 512,挤旧 +
spilled 单调计数)+ `BusSnap`(svctl.list 行 + 逐插件服务 id 目录)。
锁粒度:每次操作 lock/clone,不跨帧持锁;`snapshot()` 返回 `Arc<BusSnap>` 零拷贝。
- `plugin.rs`:`UiPlugin`("ui"):routes = `eLog`/`ePlugUp`/`ePlugDown`/`eTimer` +
`eTick.`(行情带唯一源)+ `eContract.`/`eContractFinish`(查询合约窗数据流,
ek-ctp 登录后自动查询,UI 只收不催)。
- eLog:payload `LogData`,`is_warning_or_above` 打 WARN 标签(+UCH 转储环形);
- ePlugUp/Down:payload `PlugNotice` → 单行 host 日志 + rev;
- eTick./eContract.:payload 直落 `tick_store`/`contract_store`(键 EX.symbol);
- eTimer:`ask(svctl.list, 2s)` + 逐插件 `ctx.services_of` → `set_snapshot`;
ask 失败进日志面,不堆 panic。
- `render.rs`:`render_body(ctx, &UiShared)` 唯一渲染入口(L2;v5 面板化,
BD-UI-27/29):TopBottomPanel(菜单)→ TopBottomPanel(行情带,resizable)→
SidePanel(下单面板 + 盘口,resizable)→ CentralPanel(2×2 监控格,响应式)→
弹窗面(K线/运维/CTP/查询合约,egui::Window,面板之后)。
- `runtime.rs`:
- `ui_kinds(&Arc<UiShared>)` = station 缺省表 + ui 工厂;工厂闭包绑定**同一个**
shared → ui 插件被 svctl 摘除/再注,窗口观测态不丢(状态在 shared 不在插件体);
- `spawn_host_watcher`:`sleep 120ms` 轮询 stop 位 → `engine.close_all(3s)`;
close 顺序(逆 start 序)仍由宿主引擎决定,观察线程只送信号;
- `run_station_ui`:boot(不 start 由 boot 内部? 否—— boot 只装配)→ `engine.start()`
→ 观察线程 → eframe `run_native`;关窗 `on_close_event → request_stop` →
join 观察线程 → 返回。启动失败(start Err)以 `Err(String)` 上抛(供 S1c
launcher 的失败信号,不在窗口内吞错)。
## 执行期钉
- **BD-UI-9(UI 即插件 / 双线程形貌)**:总线 worker 写、eframe 主线程读,以
`Arc<UiShared>` 相连;不加自制通道、不退回向量共享消息队列。
- **BD-UI-10(L2 单渲染入口)**:`render_body(ctx, &UiShared)` 被真实 App 与
kittest 同调;面板组合在 render_body 内(BD-UI-29),测试走
`Harness::builder().build(|ctx| …)`(ctx 级,不再包 CentralPanel)。
- **BD-UI-7(四件套版本锁)**:eframe = egui = egui_kittest = `0.33.3`;
egui-charts 0.2.0 进入点定为 S4(K 线页),进入时与四件套重验一版。
- **BD-UI-11(关窗协议)**:`App::on_close_event` 只置 stop 位;close_all 由
观察线程完成 —— 窗口永不直接持有 `MainEngine::close_all` 调用链。
- **BD-UI-12(kittest 用法纪律,0.3.2 改口径)**:判据四类 —— ① AccessKit 语义树
(节点在不在、标签是什么、`Node::rect()` 几何);② 状态层(`UiShared` 字段/访问器,纯单测);
③ 源码机械扫描(色值只在 `tokens`、显式开关、格式只走 `src/format.rs`);
④ **本帧绘制指令**(`egui::FullOutput.shapes` 的实心矩形 / 线段 / 文字形状 + `clip_rect`)。
**图像快照与像素扫描作废**:`harness.render()` / `harness.snapshot()` /
`UPDATE_SNAPSHOTS` 一律不再用(本机没有截图也没有读图能力,详见本文件末「0.3.2 口径变更」
与交接报告 §17)。
- **BD-UI-13(纯观测零服务)**:ui 插件 `services()` 返回空;管制面唯一在 svctl
—— 界面无特权路径。动态 add 的 ask 窗口期(`on_init` 后)与 headless 同一契约。
- **BD-UI-14(启动器协议,与 ek-station bin `--from/--ready-file` 同口径)**:
清单 = 行式文本(`#` 注释;空行跳过;行内空白拒);就绪标记 = `ready\n`;
spawn 分离式(stdin/stdout 静默 + Windows `CREATE_NO_WINDOW` + stderr 管道,
**stderr 生存期 ≤ 64KB** —— 子进程不朝 stderr 泼流,违约即协议故障);
等待 = 50ms 轮询标记 + 存活检查,30s 上限(超时 kill);失败分类 `Exited{code,
stderr_tail}` / `NoReadySignal{timeout}`;成功路径 **drop Handle = 分离脱管**。
- **BD-UI-15(清单解析地面)**:pick.rs 与 ek-station main.rs 的 `--from` 解析
S1c 各自落地(等语义);S3 toml-lite 重构时统一抽到共享模块(单点 PR),避免
现在跨 crate 发版拖 III 制订的节奏。
- **BD-UI-16(pick 窗两线程形貌)**:UI 线程只画与翻位;spawn+wait 在工作线程
(30s 不冻 UI);结果回槽 `RunShared`;App 见果即关窗,
收场归 `run_pick_launcher`(join + 读槽)。ui 插件自动补入 pick 后配置,
其余勾选即装配面(与 boot 同表)。
- **BD-UI-17(隔离红标的闭环面)**:`ePlugQDown.<名>` 是定向广播(未知名无法
预订阅 —— 严格 exact-type 路由),UI 侧红标走快照行状态扫描
(`is_quarantined`,1s 节拍内落实);隔离行仍可重注入(svctl.start 对隔离行
被 Rejected(重名)拒;既定语义链 = 先 remove 再 start)。红标卡件 trace:
`ek-core::lifecycle::EVENT_PLUG_DOWN_PREFIX("ePlugQDown.")` 常量名与后缀型
一致,但仅作 reference,不预订阅。
- **BD-UI-18(操作指令的唯一通道)**:窗口面动作只 `push_cmd(UiCmd)`,
NOT 直 ask —— `PluginContext` 只在 ui 插件 worker 可用;eTimer 拍先辞行
指令再拉快照;移除走 `pending_confirm` 单槽二步(Armed→Fired;Switched =
目标迁移),取消钮可擦;重注入为单步(加性动作);拒绝类回执
(`Rejected/NoSuchName`)进日志面板,不在 UI 抛错。
- **BD-UI-19(配置面 → 2026-10-17 用户定盘撤销,零配置口径)**:原 S3 落地过
toml-lite + `setting/global.toml` 融入读法 + 状态条 + 脱敏(cfg.rs/cfg 测试
9 例/s7 kittest 1 例)。**现行口径:ek-ui 零配置、宿主零配置** —— 不读任何
配置文件,界面本身无设置;插件结构只由 `--from` 清单或 demo 缺省决定。
需要配置的插件(唯一 = ek-ctp 虚拟账号),配置面长在插件自身,激活时在插件
内完成(见「配置面撤销记录」);宿主/界面不代读不转发。cfg.rs 整块删除;
脱敏纪律(redact)随配置面一同撤销(无配置输入即无秘密暴露面;插件自带
配置面若需展示账号,脱敏规则届时由该插件自持)。
- **BD-UI-20(S4 行情面口径)**:主题面(数据)与身体面(交互)分离——
`MarketStore`持直播线上屏(eBar./eBarUpdate.),(symbol,interval) 格缓存
4096 丢头保尾,帧/更新/回灌三计数 = 「刷新不丢帧统计可见」的三处钉面;
K线 chart 实例**每帧重建**(v1 不保 pan/缩放;首帧快照入库即 review 约定
—— BD-UI-8);图表层引用在本 crate 集中(`render::render_kline_cell`
一点换器,egui_charts→egui_plot 退守路线只动此处,BD-UI-4)。
- **BD-UI-21(S4 直播面工厂纪律)**:UI 宿主的 dm 工厂在 `ui_kinds` 内换建
(real_time=true),ek-station headless 的 rt-off 工厂**不动** ——
宿主差异收在各自工厂表,不回写 ek-station、不新增 svctl 控制面;
dm 工厂 real_time 门为装配参数(缺省 true;UI 观测面要实时门 —— 非配置文件
输入,零配置口径,BD-UI-19 现行版)。
- **BD-UI-15 兑现记录(S3)**:清单地面统一抽世到 `ek-station 0.1.1::pickfile`
(已发布;`ek-ui::pick` 变薄转发,launcher/bin/测试共一处,双实现消灭)。
- **BD-UI-22(S5 纸面网关契约纪律)**:sim 前三口直接复用 ek-ctp 的
Req/Reply 类型(SubscribeReq/SendOrderReq/CancelOrderReq 及回包)——
同构 = 同一类型面,接线换 `sim.` 即插真网关;第四口 `query_rate` 为 sim
自定(ek-ctp 0.2.0 无此口,CTP 流控语义镜像)。撮合规则钉:限价成交于
委托价、市价成交于 close/last、bar.volume = 本拍成交预算、挂单 FIFO ——
手数排队由此可断言。撮合器 `SimMatcher` 纯逻辑零总线(金样本直断),
插件壳只做路由/锁/上总线(锁内撮合锁外 put,与 DmPlugin 同形)。
sim 不引 ek-oms:成交经 eOrder./eTrade./ePosition. 总线入账由 OmsPlugin
被动完成(插件不直调铁律不破)。
- **BD-UI-23(S5 监控页读数纪律)**:监控页读数 = ui 插件 eTimer 拍 ask
`oms.get_all_*` 全量查询帧(与 headless 同源契约;oms 不在 → 空帧不抛错);
下单/撤单/流控三口走 UiCmd → ask `sim.*`(窗口面零直调)。撤单钮只挂
ACTIVE_STATUSES 行(与 ek-data 同口径)。kittest 点击纪律:**`click_accesskit`
取代坐标 `click`** —— 坐标点击对折叠线下(clip 外)节点无效,accesskit
动作可达不可见控件;`get_all_by_label` 精确匹配、`query_by_label_contains`
子串匹配且多命中即炸,撞名列按语义选面。
- **BD-UI-24(S6 CTP 手工配置面,2026-10-17 集成测试战役)**:「插件需要配置 →
配置面在插件自身」的正向界面落地 —— CTP 段 9 输入位全部**人敲**(界面与宿主
不读文件;密码/AuthCode `password(true)` 掩码不回显),`连接 CTP` →
`UiCmd::CtpConfigure(CtpForm)` → ask `ctp.configure`(ek-ctp 0.3.0
D-CONFIGURE:校验/守卫复位/发起归插件,md 账号回落也在插件侧裁决 —— 表单
`to_configure_req` 不预处理);`订阅行情` → ask `ctp.subscribe`(订阅位
逗号/空白解析,空位红字不入列)。`UiCmd` 携 `CtpForm`(全 String 可 PartialEq;
ek-ctp `ConfigureReq` 无 PartialEq,抬线在 ui 插件出队点一次转换,装版不动)。
资金小节 = `oms.get_all_accounts` 帧(可用 = balance−frozen 由 AccountData
自算)。native 经 feature `ctp-native` 透传(默认关;实连战役开,工厂
`with_native` 在 ek-station 侧)。装配序纪律:要观察插件 on_start 的日志,
ui 须在清单里先于该插件(观察序 = 装配序,c0 钉)。
- **BD-UI-25(票据网关选择子,2026-10-17 战役)**:票据新增「网关」位(空 =
`sim` 纸面;填 `ctp` = ask `ctp.send_order`/`ctp.cancel_order`);`UiCmd`
随单携 `gateway`;行级撤单钮跟随委托来路 `gateway_name`(空兜底 sim)。
- **BD-UI-26(CJK 字体回退,2026-10-18 乱码根因)**:epaint 0.33 缺省字体组
(Hack/Ubuntu-Light/NotoEmoji/emoji-icon)**无 CJK 字形** → 中文豆腐块。
`fonts::apply_cjk_fonts` 启动时从系统字体候选表装载一枚 CJK 字体追加
Proportional/Monospace 两族末位(**只收纯 .ttf/.otf** —— epaint 走 ab_glyph,
不吃 .ttc 集合且解析失败即 panic;VF 按缺省实例解析,排位靠后);两处
run_native 取帧闭包接线(runtime + pick 窗)。验收三钉:
f0 字形 API(22 常用汉字 has_glyph)、s16 汉字密集页快照 baseline、实窗
stderr 证据行 `[ek-ui] CJK 回退字体已装载: <path>`。界面文案保持中文单语
(用户定盘:要的是不乱码,不是双语切换)。
- **BD-UI-27(v4 带框架 + 订阅两入口,2026-10-18 布局重做定稿)**:
① **带框架(Python 主界面等价)**:菜单栏(运维|网关|查询合约|帮助)→ 行情带
(全宽,tick 唯一源,交易所二级 tab,双击行弹 K线窗)→ 交易带(左下单面板
9 字段 + 委托/全撤 + 右 2×2 监控格「委托|未成交」「成交」「持仓」「资金|日志」);
回灌/票据界面面退役(总线面 UiCmd::Reload 保留);默认窗 1280×860。
② **订阅两入口(用户裁定「订阅不在网关窗定死」)**:下单面板代码框回车 =
CtpSubscribe(bare symbol,Python returnPressed 等价);菜单「查询合约」弹窗
(Python ContractManager 等价:查询框留空 = 全部 + 勾选 + 订阅选中)。CTP 网关窗
无订阅字段。UI 对合约流只收不催(无催查询总线口)。
③ **egui 0.33 帧内 sizing 退让钉(实窗截图审出)**:帧内 Grid 的 sizing 预估遇
ComboBox 高行会退让,网格后挂的面板内容会叠回网格 → 下单表单 = 网格(按钮/
提示语都是网格行,网格后不挂内容),盘口独立帧,交易带整体包 ScrollArea 保小窗
可达(D7 口径)。④ **kittest 钉**:建构面统一 `harness_for()` 1280×860(实窗
同源);点击帧断言用 `harness.step()` 单帧(`run()` 空闲帧会撤走单帧红字);
撞名 label(委托钮/tab/表题)用 `get_all_by_label` 按渲染序取。
- **BD-UI-28(第五轮审计,2026-10-18 复审修正)**:
① **回车订阅真缺陷(实码修复)**:egui 单行 TextEdit 遇 Enter 即
`surrender_focus`,`has_focus()` 在检查时已 false —— 原 `has_focus +
key_pressed` 口径下回车订阅**实窗从不触发**;改规范口径 `lost_focus() +
key_pressed(Enter)`(s20 钉实测)。回车输入带 `EX.` 前缀则剥成裸码并回写
表单(与查询合约窗同口径,ctp.subscribe 收裸码)。
② **悬空面清除**:`selected_symbol` 四处写零读(注释声称「联动下单面板」
未兑现)→ 字段/访问器删除;行情带行**单击 = 带入下单面板**(方案「行单击
= 选中合约」落地,s8 钉);委托行/持仓行单击无动作(Python 同口径)。
③ **委托行双击 = 撤单**(Python OrderMonitor `itemDoubleClicked→
cancel_order` 等价,活动行限定,s11 钉;原实现双击弹 K线为偏离,已撤 ——
K线入口唯一在行情带双击)。④ **表列补全(Python 蓝图逐值抄录)**:委托表
13→17 列(+名称/交易所/类型/来源=reference)、成交表 10→12(+名称/交易所)、
持仓表 11→16(+名称/交易所/在途/市值=market_value/成本=price);数据面
OrderData/TradeData/PositionData 字段全在场,零新增依赖。⑤ **tick_of
交易所感知**:交易所非空 = EX.symbol 精确匹配(跨所同码不串),空 = 仅按
代码。⑥ **kittest 双击口径**:`step()` 每事件一帧且缺省 step_dt=0.25s →
两击释放间隔 0.5s > egui `max_double_click_delay` 0.3s → 双击钉用
`with_step_dt(0.1)` 建构(s11);同帧四连击不可用(每事件一帧)。Node 借用
harness 树,取 rect/id 后再 step(借用检查);`Role::TextInput` 非 TextBox。
⑦ **Frame 布局泄漏钉(s21 满表快照显形)**:`Frame::show` 继承父级
`ui.horizontal` 的横向布局 → 2×2 格内 tab 行/表题/宽网格横流挤同一行
(空表期隐形,满表现形)→ `group_frame` 内容强制 `ui.vertical`(行情带同款
口径:短行成行,宽网格末位溢出走横向滚动);下单面板帧不动(其内容 = 网格 +
罕见 err label,横流反而护住 BD-UI-27 叠行钉)。委托表表题撤除(tab
「委托|未成交」即 Python 表身份,三重撞名降为两面,s12 口径同步)。
满表视觉钉 s21(委托 17/成交 12/持仓 16 列入 baseline)。
- **BD-UI-29(面板化重构,布局 v5;实窗演示暴露 + 官方文档核对)**:实窗
1280 演示暴露三症:① 交易带定宽 250+140+480+480+间距 ≈ 1438 > 1280 →
右列裁切且带级 ScrollArea 只开纵向 → 裁掉内容不可达(违 BD-UI-27 可达
口径);② 2×2 格定高 250 实际只渲染 ~85px → 底部死区 ~300px;③ 菜单/两带
全挤单 CentralPanel 手工嵌套,无 Python splitter 语义。
- **机制实锤(dbg 探针)**:`[row] avail=[9984, 18]` —— egui horizontal 行
成员的可用高度初始只有一行高(interact_size),**高度分配不沿横向向下
传播**,行按内容收缩 → 格内 `ScrollArea.max_height` 被钳到最小滚动尺寸,
定高失效(②的根因;①为定宽算术,1438>1280 可复现)。
- **官方口径核对**(egui 0.33.3 源码模块文档 = docs.rs 同源;crates.io 包
不含 examples/,egui.rs 书为 JS 渲染页):panel.rs「面板是放控件的正规
区域;顶层面板不得互相嵌套;CentralPanel 必须最后加;Window 挂面板之后」;
`TopBottomPanel::resizable/default_height`、`SidePanel::resizable/
default_width` = QSplitter 官方等价;resizable 前提 = 内容吃满可用空间
(ScrollArea auto_shrink(false) 满足)。
- **落地**:render_body 改收 `&egui::Context` 自开四面板(菜单 → 行情带
240 可拖 → 下单侧栏 250 可拖[下单面板 + 盘口纵排,Python TradingWidget
内序] → CentralPanel 最后 → 弹窗);2×2 格响应式 `allocate_exact_size`
预留 (可用宽-间距)/2 × (可用高-间距)/2 矩形 + `new_child` 在预留矩形内
开 Frame+ScrollArea(高度才吃满;`add_sized` 收 impl Widget 不适用闭包
内容,已试撤);`group_frame` 保留 `ui.vertical` 泄漏钉(BD-UI-28⑦ 延续)。
- **BD-UI-28⑦「下单面板帧不动」被本钉取代**:面板化后下单侧栏内容天然
纵向,err label 后置改由 ScrollArea 吸收 sizing 退让(BD-UI-27 口径不变,
护垫从「横流侥幸」换成「官方吸收面」)。
- **钉**:s3 加结构钉(右列存在贴边不裁切/底行沉底无死区/侧栏在左/两列
等宽随窗分配,先于字体让位执行);快照 s3/s10/s16/s21 重基线;行为钉
s0–s20 全数不动全绿;kittest 建构面 `build_ui` → `build`(ctx 级)。
## 与 ek-station 的边界
ek-ui 依赖 `ek-station = "0.1.7"` / `ek-ctp = "0.5.0"`(装版;禁 path —— 同纪律)。ek-station
本 bin 保持 headless 不引 ek-ui;UI 总装配在 ek-ui(`ui_kinds` 复用 `default_kinds`
+ ui 工厂),launcher(S1c)也在 ek-ui 侧。
## 审计修正记录(2026-10-17 全面复审)
- **BD-UI-22 补钉(撮合两守卫)**:① **tick/bar 双重计价防护** —— tick 成交
挂账 `tick_filled`,bar 收口预算 = bar.volume − 本段 tick 已成(同一笔市场
量只吃一次;u5 钉);② **bar 单调守卫** —— 同 symbol `bar.datetime` 非严格
递增的帧(重复/重放)直接忽略,不重复撮合(u6 钉);③ ask 臂锁面开块
(match 暂存守卫会跨臂存活 —— 锁内造单锁外上总线,与 on_mail 同形)。
- **ek-station 0.1.2(pickfile BOM 剥离)**:Notepad 系 UTF-8 BOM 使首行注释
识别失效 → 整表 BadLine 拒启(活跑实测撞到);read 剥 BOM,p3 钉。
- 复审确认未动面:ek-station headless 工厂表(rt-off)未波及;已发布六仓仅
rustfmt 1.8.0 纯格式对齐(无语义变);setting/global.toml 只读;
ek-ctp/ek-oms 契约类型只引用未改。
## 配置面撤销记录(2026-10-17 用户定盘)
- 用户口径:**界面零配置 + 宿主零配置** —— ek-ui 不读任何配置文件;station
侧也不预读配置转发。插件结构只由 `--from` 清单或 demo 缺省决定。
- **配置归插件自身(定盘)**:激活列表(pick 窗勾选 / svctl start 重注入)时,
哪个插件需要配置,配置面就在该插件内部完成(插件自带配置面/配置服务);
宿主与界面都不代读、不转发、不集中。暂时不需要任何多配置机制。
- 全生态唯一需要配置的插件 = ek-ctp(虚拟账号)。其配置面归 ek-ctp 插件自身
(S6 接线时按此定盘落地:激活 ctp 时配置在插件内完成,不落共享配置树)。
- 落地:cfg.rs(toml-lite/UiConfig/locate_setting/redact)+ CfgStatus 状态条 +
`--config` 形态 + 发现链整块删除;cfg 测试 9 例 + s7 kittest 1 例随撤;
测试 51 → 41。**setting/ 目录已整树清理(2026-10-17 定盘)**:global.toml +
backtest/common/live/test 分层样例(含凭据档)全部删除——插件配置自持,
工作区不留共享配置文件;Python 侧 c:\dev\enkeep_trade_setting 原档不在
清理范围(只读纪律,原样未动)。
## BD-UI-30(0.1.1 薄下单者,OMS 中枢链,2026-10-19)
- **裁定**:下单/撤单直发 `{gw}.send_order`/`{gw}.cancel_order` 的旁路口
**删除**——UI 是薄下单者,管制面收敛到 oms 两服务:
`UiCmd::SendOrder{req, gateway, mode}` → `oms.submit_order`(五段管线:
入口校验/拆单槽位/风控槽位/发送段/记账段;parent 聚合 SENT|PARTIAL|REJECTED
逐腿显形进日志);`UiCmd::CancelOrder{orderid, gateway}` → `oms.cancel_order`
(守卫 4000 不可绕过;symbol/exchange 不再随令携带,oms 订单缓存解;
全撤 = 界面侧逐个扇出到同一守卫口)。BD-UI-23/25 的「ask sim.*/ctp.*」
口径自此被取代(历史记录保留)。
- **「自动开平」勾选(OrderForm.auto_split,默认开)**:拆单四态的 UI 映射 =
Auto(勾选)/None(取消,原样旁路);风控与守卫不可旁,拆单可逐请求旁
(DESIGN ek-oms §6.2 口径的 UI 面)。s22 钉勾选双向 → mode 逐位。
- **装配口径统一**:ui_kinds 复用 station 0.1.7 `default_kinds`——oms 已装配
ek-risk 三闸(standard_gates 缺省限额),面板与 headless 同一台 oms。
- **fixture 退役**:ui_converter_chain 从「offset 夹具进链」升格真管线链
(拆单器入驻 oms 管线,夹具失去存在意义;dev-dep ek-converter 删除)。
顺带实锤忠实初始化口径:无成交史的首轮 `ePositionDone.` 只记时刻等下一轮
(原稿 1268-1288/1282-1285),第二轮距首轮 ≥3s 才置位——链测补第二轮。
- **实连钉**:ui_ctp_live 第 13 步(有仓则 CLOSE 经管线 = CLOSETODAY 实连
证据;7×24 快照市无成交 = OBSERVE 如实记档)。13 步 110s 全收场。
- **依赖**:ek-oms 0.2.1→0.3.1、ek-station 0.1.5→0.1.7(强制 bump,防同构建
两份 oms)。
## BD-UI-31(0.2.0 界面体验轮,2026-10-19;用户四点裁定)
- **R1 菜单一键直达**:「运维/网关/查询合约」menu_button→下拉→入口钮的
两层结构废除,菜单钮即入口,单击直接弹窗(「帮助」保留菜单——多行内容)。
破坏性:三个入口钮 pub 常量删除(标签 = 菜单名)。
- **R2 成交表方向上色**:方向列空绿/多红,补齐与委托/持仓表的一致口径
(此前成交表方向为裸文本 = 遗漏,用户实看指出)。
- **R3 表格行选中 + 悬停跳色**(`UiShared::row_selected/select_row` 新口 +
`paint_row_bg` 渲染工具):委托/未成交/成交/持仓/查询合约窗行首格单击 =
选中行(蓝调半透明跳底,每表一行,换行即换选);行情带选中行 = 下单面板
当前合约(带入即跳色,不另存态);全表悬停 = 淡灰底。行首格既有双击动作
(撤单/带入/K线)全保留——egui 双击先经 clicked,选中顺带发生,无冲突。
叠底为半透明(行文字先画,叠后仍可读)。
- **R4 标题文字撤除**:「行情带(唯一源…)」「下单面板」「盘口(合约)」三处
题字整行删(带位/侧栏即身份;Python TickMonitor/TradingWidget 无题同口径;
盘口括号合约代码一并删——选中合约由下单面板代码格与行情带跳色行指认)。
- **不动面(用户裁定)**:K 线窗标题保留合约;运维窗维持浮动弹窗形态
(O1 内嵌页方案否决);「帮助」菜单保留。
- **钉**:s23 行选中(委托换选 + 成交独立槽);s17 改单击直达;快照重基线。
- **0.2.1 续(动作归行,用户裁定)**:行内任何一格单击/双击都触发该行动作
(`click_cell` 标签矩形叠感应,外观不变);行情带盘口四列带方向
(`import_tick`:买1 价/量 → 卖,卖1 价/量 → 买,价格列顺带填价,其余列
方向/价格保持默认)。r8 钉逐列口径。
## BD-UI-32(0.2.2 字号与独立窗轮,2026-10-19;用户三点裁定)
- **R1 全局字号上调**(`apply_ui_style` 新口):body/button 16、small 14、
heading 22、monospace 15(egui 缺省 13/12/18/12)——用户实看「整体字偏小」,
对照 Python 版;render_body 顶部施加,真实 App 与 kittest 同源(快照重基线)。
- **R2/R3 查询合约窗 + K线窗独立 OS 窗**(`show_viewport_immediate`):
原生最小化/最大化/关闭钮(此前 egui::Window 只有关闭钮,且被物理关在
主窗矩形内不能拖出——用户对照 Python 独立窗形态指出);可拖到屏幕任意
位置、独立任务栏条目。OS 关闭钮 = close_requested → 状态位翻负 → 父窗
下一拍不再 show → eframe 销毁窗。Embedded 回退(后端不支持多窗:
kittest/WEB)= 主窗内弹窗同一本体,测试断言面零改动。
- **不动面**:运维窗/CTP 窗维持主窗内弹窗(用户只点了这两个窗)。
- **选型**:immediate 而非 deferred——回调可捕获 `&UiShared`(UiShared 非
Clone,deferred 需 Arc 化会动 render_body 签名 = 无谓破坏性);StationApp
1s 节流重绘节奏下 immediate 的父子联动重绘代价可忽略。
- **0.2.3 勘误(关不掉根因,用户实看指出):** egui 销毁判据 = 子 viewport
本拍未 used——0.2.2 调用点未按状态位守卫,每拍 show = 每拍 used = OS 窗
永不销毁(X 翻位后又被 show 回来,等效 CancelClose),且窗在主窗一开就
自己出现。修复 = show 整段包进 `contracts_window_open()` 守卫。测试面
教训:Embedded 回退掩盖 Immediate 真窗面(21 绿 ≠ 真窗面正确),s24 注入
recording renderer 补钉;复审纪律补条:新机制的每个调用点必须按状态位辞行。
## BD-UI-33(0.2.4 四窗一型独立化,2026-10-19;用户裁定)
- **运维窗 + CTP 网关窗也转独立 OS 窗**(「四窗皆可脱离主窗」):与查询合约/
K线同形态同守卫;Embedded 回退维持主窗内弹窗(kittest 断言面与快照零改动)。
- **合约表吃满窗口:** 撤 max_height(260) 内嵌时代残留;动作行(计数 +
订阅钮)前置,ScrollArea auto_shrink(false) 吃满剩余窗高——放大窗口 =
表格变高(用户「放大窗口意义在哪里」的直接回应)。
- **`show_independent_window` 助手(四窗一型):** 守卫模式内建(0.2.3 教训
固化成调用方纪律:位关不 show = GC 销毁);Embedded/Immediate 两面的
"被关"统一以返回值出表,消除四份 viewport 样板重复。
## BD-UI-34(0.2.5 表头固定 + 主题 + K线最大化/ESC,2026-10-19;用户裁定)
- **表头固定(`pinned_table` 五表全上):** 官方 DIY 模式(scroll_area.rs:412
注释「painting a sticky header on top of it」)——单 grid 实例(头行+体行)
保列对齐,ScrollArea 之后以本拍捕获的表头格矩形重画表头带(背景盖行 +
Label 重画;矩形已含滚动平移 → 横向跟随、纵向盖顶、零拍滞后;Label 无
交互 sense → 点击穿透)。两轮机制实锤:①vertical 嵌 horizontal 高度塌缩
(s3 底行钉 462<500 抓到);②同 id 双 grid 各从空白重建 State,save 交替
request_repaint = 重绘环(kittest max_steps 爆)——两案皆弃,留 overlay 案。
- **主题四套(金融深色默认/egui深/egui浅/Qt灰白):** 菜单钮一键循环;
Theme 枚举 + AtomicU8 运行期态(零配置口径);apply_ui_style_for 统一施加
(主窗/独立子窗/kittest 三处同源)。语义色跨主题恒定。回答用户「颜色是
默认还是自配」:此前 = egui 默认深色骨架 + 自配语义色(非全默认)。
- **K线窗:** maximized 打开即最大化(s25 钉 builder.maximized=Some(true));
ESC 快捷关闭(body 返回 true → close_kline 同链;s25 钉 Embedded 面注入
Event::Key;Immediate 面键按子窗焦点路由)。
- **行情带外层 ScrollArea 不可撤:** resizable 面板吃满前提(BD-UI-27),
本轮实撤一次即 s3 底行钉实测塌到 462——已恢复并注释钉死。
## BD-UI-34 返修(0.2.6,2026-10-19;用户实看三报,全有机制实锤)
- **竖线(资金右):** 覆盖带 ui.put 视口外表头格 → 布局 min_rect 撑宽 →
Frame 描边矩形撑到 x=1222(= 346.9 + 16 列总宽)。二分实锤:关覆盖带线
消失;空内容时线跟内容宽走(1007)。修 = painter.text/rect_filled 纯绘制
(零布局副作用)+ clip 裁越界。教训:覆盖/装饰一律走 painter,不得用布局
调用(put/label)画视口外内容。
- **表头缝:** 带高须 = 表头格高 + item_spacing.y(3px),否则滚动行从间距带
露出。
- **空表缩格:** 空态不得提前 return——pinned_table 照调(空 body),表头与
吃满滚动区在场。s26 钉三表空态表头同在场。
- **group_frame_plain 使用纪律(0.2.6 钉死):** Frame::show 描边矩形按内容
min_rect 收缩(实锤:label 内容时 gw 宽缩到 112)——只配 auto_shrink(false)
填充物(pinned_table)使用;非表格格(资金/日志)必须走带填充 ScrollArea 的
group_frame,否则 Frame 收缩、右边框落行中段。
## BD-UI-35(0.2.8,2026-10-19;用户实看三报,全机制实锤)
- 行情带状态行「tick N 格 / 最后更新」= 用户裁定撤除(考证:从未删过,
0.2.0 撤的是题字)。s8 反向钉防回归。
- 表头缝硬根因 = clip_rect_margin(默认 3.0)外扩裁剪;覆盖带必须按
inner_rect ± margin 起画。s27 当时用「滚动快照 + 像素扫描」核,0.3.2 起换成
裁剪矩形 + 绘制指令(同一判据,不同取数)。
- 空态提示一律在表头之下(grid body 首格),不得再置于 pinned_table 之前。
- 列头单源纪律扩散到全部四张格表(const HEADERS):本轮自审又抓到两处
空态分支凭记忆写错——手写第二套表头 = 必错模式,单源是唯一防线。
- 快照回读串行化(SNAP_LOCK):本机 wgpu 并发回读会随机成组失败,测试闸
对此环境敏感;新增快照测试必须走 snap() 不得裸调 harness.snapshot。
**0.3.2 作废**:快照与像素回读整条撤除,这把锁也随之删除(见文末「0.3.2 口径变更」)。
## BD-UI-36(0.2.9,2026-10-19;纸面网关迁出界面包)
- 用户裁定:纸面网关 = 网关,应与 ek-ctp 同级独立成包(Python 蓝图 PaperAccount
即与 CTP/Xt/Qmt 同级独立 app;SimNow 登不上时切换的本地模拟流)。
曾议落位:留 ek-ui(否决:界面包藏网关,无头测不到)/并入 ek-ctp(否决:污染
纯 CTP 协议面)→ 裁定 = 独立 crate ek-paper。
- 语义零迁移实锤:旧 sim.rs 与新 matcher+plugin 非注释代码行 449↔449,
Compare-Object 仅 4 处 Arc 写法面差异。
- 工厂表归一:sim 登记项上移 ek-station 0.1.8 default_kinds(无头/有头同一张表);
ek-ui ui_kinds/pick_kinds 撤自建 sim 项防双份(注册名冲突 = add_plugin Duplicate)。
- 教训钉(注册表耦合顺序):改工厂表必须【先发布被依赖方】——station 0.1.8
未发布前 ek-ui 测试 UnknownKind("sim")(本地改动对 registry 依赖不可见,
这正是禁 path 耦合的代价与回报)。
## BD-UI-38(0.2.10,2026-10-19;纸面网关整体退场)
- 用户裁定(2026-10-19):纸面账户功能整体撤除,界面不再有 sim——
**ctp/SimNow 两套前置(12 小时/7×24,选前置用 ek-ctp scripts/simnow-fronts.ps1
-Probe)已满足当前需求**。BD-UI-37(sim 对外面对齐 CTP 的改造方案)作废,
未执行半成品整堆丢弃(ek-paper 工作区回滚到 0.1.1 已发布态)。
- 退场涟漪(三包):ek-station 0.1.12 撤 ek-paper 依赖 + 工厂表 sim 项
(pipeline_headless 改 dm/oms/ctp 装配 + CTP 回报行直发注入,账本/行情/因子
钉数值零变);ek-ui 0.2.10 撤 ek-paper 依赖 + 下拉/兜底/钮/测试全清;
ek-paper 0.1.x 档案原样留存(不删仓库不发版不 yank——yank 反咬已发布老版)。
- 界面口径:接口下拉 ∪ 集改 {ctp};parse_gateway 空兜底 sim→ctp;运维窗
「流控查询」钮撤(UiCmd::QueryRate 变体同步撤——公共 API 减项);
demo 窗 = dm/oms/ui(网关经运维窗激活);demo_tour 纸面段整段删(8 步重编)。
- 测试口径:ui_sim.rs 整删(9 钉);ui_converter_chain 网关位改测试本地
"ctp" 受理夹具(受理形制忠实 ek-ctp:SUBMITTING 单上总线 + ok 回执 +
`{gw}.` 全号;成交按 OnOrderTrade/OnRtnTrade 同构行直发)——本钉主体
一直是 oms 五段管线,与网关撮合无涉,换顶替零减钉;kittest 夹具标签
sim→ctp(五张快照重基线:s3/s10/s16/s21/s27,钮撤除 + 标签改名像素变);
71→62 钉(减的 9 = ui_sim)。
- 纪律钉:历史条目(BD-UI-36、CHANGELOG 0.1.8/0.1.9 sim 入表等)是史实,
不改写;退场只记新条目。
## BD-UI-39(多账号面:账户面板取代网关窗,规划 docs/ek-multi-account-plan.md)
> 本条取代的旧口径(史实保留不改写):BD-UI-24 的 `连接 CTP` → 硬编码
> `ctp.configure` / `ctp.subscribe`;BD-UI-27/33 的「CTP 网关配置窗」单账号窗体;
> BD-UI-38 的接口下拉 ∪ {ctp} 与 `parse_gateway` 空兜底 `"ctp"`。
- **菜单「网关」→「账户」**:旧单账号网关窗退役,收纳为账户面板的添加/编辑表单
(9 输入位 + 密码掩码 + 收录勾选原样沿用,`ctp_grid` 未改字段)。旧窗的死面
(`ctp_window` 状态位、`MENU_CTP`、`BTN_CTP_CONNECT`)一并删除,不留"供旧测试引用"。
- **账户清册在界面侧**(`UiShared.accounts`):行 = 派生注册名 + broker + 账号 +
在场位 + 元账号标记 + 表单留档。派生名唯一入口 `ek_ctp::derived_gateway_name`
(界面不自己拼字符串);密码只在内存,本轮不持久化(规划 §10.2)。
- **添加时序钉(规划 §5.A)**:一个钮两条 cmd —— `Start{factory:"ctp", name:<派生名>}`
先装配,`CtpConfigure{gateway:<派生名>, form}` 后注入凭据(凭据不进 svctl.start 通道)。
- **单源纪律在"添加"这一刻定源(规划 §5.C)**:无元账号且无在场账户 → 新账户自动成为
**行情元账号**(md 前置保留;清掉它等于这个账户自己也永远收不到 tick);已有元账号或
已有在场账户 → 按纯 TD 装配(md 前置清空)。订阅两个入口(下单面板代码回车 /
查询合约窗勾选)一律经 `subscribe_target` 路由到元账号,未指定就明确报错不静默发。
- **元账号连接后锁定(用户裁定「账户连接后不允许切换」)**:任何账户已装配即拒切换;
换源流程 = 撤挂单 → 移除 → 改配置 → 重连 → 重放订阅(订阅集 `note_subscriptions`
登记,元账号新装配后自动重放)。
- **移除守卫**:该账户有活跃委托 = 不给移除钮(只给"先撤单才能移除"提示);有持仓 =
可移除但警告"柜台侧不受影响,重加后由轮转查询重建视图";两步确认沿用 `confirm_step`。
- **网关名回落只剩一个阶梯**:`default_gateway()` = 上次手动选择 → 首个已装配 → 首个;
一个账户都没有 = 禁用「委托」钮并给提示(旧"空 → 字面量 ctp"兜底退役,B4)。
撤单三处(行内钮 / 全撤 / 行双击)一律随该单自己的 `gateway_name`。
- **诚实标注钉**:账户表状态列写「已装配 / 未装配」不写「已连接」——界面只能从
`svctl.list` 看见实例在场,登录成败 ek-ctp 无状态口可查(开放项)。
**(0.2.19 已接上:`{gw}.status` 落地,状态列改两段口径「已装配·<登录态>」;
「不写已连接」这条口径不变,见文末 0.2.19 节。)**
- **测试口径**:s13 重写(添加账户 → 派生名 + 两条 cmd + 首个账户自动定源 +
第二账户 md 清空不被抢源)、s17 菜单改「账户」、s18/s20 订阅路由到元账号、
新增 s28(元账号锁定 + 挂单移除守卫 + 守卫解除);五张快照重基线
(s3/s10/s16/s21/s27,菜单标签与 tick 列名「接口」→「行情源」像素变)。
## 0.2.19(接上网关状态口 + 『断开』钮;规划 docs/ek-ui-account-tabs-plan.md P3,2026-10-22)
事实源换成了 ek-ctp 0.9.0 的两个服务口(`{gw}.status` / `{gw}.disconnect`)。界面侧的六条口径:
- **D-UI-STATUS-1 状态列两段,不合并**:装配位(`svctl.list` 在场)与登录态(`{gw}.status`
读数)是两件事,拼在一栏显示(`已装配·在线` / `·掉线` / `·登录失败` / `·查询断供` /
`·已断开` / `·登录态未知`)但**不合成一个布尔**。0.2.18 的「已装配·登录态未知」保留为
读数缺失时的显式档位 —— 缺口从「不可见」变成「本轮还没问到」,两者必须长得不一样。
- **D-UI-STATUS-2 只认 `logged_in`,不认 `handle_up`**:网关的「句柄已建 / registerFront 已发」
在掉线时仍是真(ek-ctp DESIGN D-STATUS ①)。界面照抄它 = 替掉线的账户谎称在线(s48 读数二钉)。
同理 **"从未回报过"不当"0 秒前回报过"用**:`td_last_account_ms = None` 是"没有这件事",
不是"很久没做"。
- **D-UI-STATUS-3 阈值住在界面**:「查询断供」= 连续被拒达 `QUERY_REJECT_STREAK_LIMIT`(3)
**或** 资金回报超 `ACCOUNT_STALE_SECS`(30 秒)。网关只给原料(计数与时刻),"几次/多久算断供"
是界面口径 —— 它才知道自己多久刷一次、多久问一次。覆盖位 `set_status_thresholds` 已开,
配置文件面还没定(规划 Q10 只说"进配置",ek-ui 现在零配置)。
- **D-UI-STATUS-4 轮询只问"已装配且在场"的行**:每 `STATUS_POLL_TICKS = 2` 拍问一次。
实例被摘除后清册行还在,照问不误就是每次等满 2 秒超时、按账户把 eTimer 刷新循环拖住(c5 钉)。
状态口在 ek-ctp 侧只读、不写日志,所以这条轮询不吃 eLog 环(512 行环保护不变)。
**问不到就退回「未知」并写明原因**,上一轮的「在线」不得留档(s50)。
- **D-UI-DISCONNECT-1 断开 = 停会话,移除 = 停实例,两条口不共用**(规划裁定 1):
『断开』发 `UiCmd::Disconnect` → `{gw}.disconnect`;『移除』发 `UiCmd::Remove` → `svctl.remove`。
断开后装配态不变、徽标「已断开」、恢复入口仍是『连接』(插件侧走「已在场 → 重投凭据」。
**同一道活跃委托守卫两者共用**:会话一停就撤不了单也不再回报,与『移除』要挡的是同一个
观测黑洞(规划 §5.B),出口也同一个(『先撤 N 笔』)—— 这是执行自查补上的一条(s54)。
- **D-UI-STATUS-5 实例不在场即作废读数**:`sync_accounts_connected` 把行从「已装配」打回
「未装配」时,登录态与细节一并清零;渲染侧只给已装配的行印状态细节(s55)。
留着就是替一个已经不存在的会话说话。
- **纯行情账户看 MD 半边**:判据是"该行 td 前置留档是否为空"。TD 半边对它是全假的,
照抄只会得到「未知」——那是假信息(s53)。
- **测试口径**:kittest 45 → 53 钉(s48 三档读数与旧读数不留档、s49 断供两判据与阈值覆盖、
s50 问不到退回、s51 『断开』入列与徽标翻转、s52 未装配无『断开』钮、s53 纯行情看 MD、
s54 断开共用活跃委托守卫、s55 摘除即作废读数);s37 口径翻转;总线级新增 c4/c5
(真起一个 ek-ctp 实例:服务目录含 `.status`/`.disconnect`、ask 真拿到 `GatewayStatus`
—— 读数里没有「问不到」就是两份 ek-ctp 没静默断的证据);s39/s40/s41 快照重基线。
## 0.2.20(三条开放口径落进界面 + 接 ek-ctp 0.9.1 分项;2026-10-22)
- **D-UI-F4-SCOPE 勾选的作用范围写死成一句话,两处同源**:勾选只管**这个网关自己**收录并上抛的
合约;公用合约表 = 在场各网关的并集(规划 D3「合约与行情公用」的直接推论);改勾选须
『连接/重试』才下发。为什么必须写在界面上:实连 S14b 里账户 B 改勾选「期货+期权」后公用表
仍 852 条、期权 0 条,而界面没有任何地方区分「没生效」与「网关没查到」—— 让用户猜就是
把口径缺陷转嫁给用户。**同一口径不得只写在一处**(面板底部 + 配置弹窗,s57 数到 2 处)。
- **D-UI-Q17 运维窗对账户清册实例只列不操作**:名字在清册里 → 那一格改写成「起停归『账户』面板
(带撤单守卫)」,不给『移除』也不给『重注入』(后者对命名实例本来就是 unknown factory)。
理由不是"整洁",是**守卫可绕过**:账户面板的『移除』带活跃委托闸 + 二次确认 + 持仓提示,
运维窗那条是同一动作的无守卫副本,两条路并存等于守卫形同虚设。**清册外的 `ctp_*` 实例仍留
这条出口**(手工起的 / 旧流程残留没有别的退路)—— 边界收口不等于把门焊死。实例仍列在表里:
砍的是操作,不是运维可见性(s56)。
- **D-UI-LOG-WINDOW 日志窗自报是窗口不是历史**:溢出计数原来只有数字;现在同一行接上去处
(「登录/掉线/查询被拒看状态列与状态细节,别回扫这里」)。依据是巡回 G1 的实连证据 ——
90 秒没抓到「交易服务器登录成功」行而资金行下一拍就刷新,**登录其实成功,是 512 行环被
状态推送流挤满**。取证的事实源自此是网关状态口(0.2.19 起),不是日志回扫(s59)。
- **D-UI-STATUS-6 分项点名但不另开一行**:ek-ctp 0.9.1 的 `td_instrument_rejects` 接进状态细节
句尾(「查询连续被拒 3 次;合约查询被拒 2 次」),同一件事不重复点名,分项为 0 不加句子。
总数与连续串**不分路**是对的(消费侧要的是「查询这条路连续被拒几次」),但合约一条没到手时
下一笔单会被 ContractGate 以 `contract:unknown` 拒掉 —— 状态列只写「在线」就是假信息(s58)。
- **测试口径**:kittest 53 → 57 钉(s56 运维窗收口且工厂插件照旧、s57 口径两处同源、
s58 分项三段、s59 日志窗口径);s39/s40/s41 快照重基线(面板底部多一行口径句);
`.gitignore` 补 `**/snapshots/**/*.old.png`。
## 0.2.21(第三条断供判据:「从未到手」也要超时;2026-10-22,依赖不变)
- **D-UI-STATUS-7 「从未发生」不能只是"没有这句话",它自己也要有超时位**:0.2.19 立「`None` 不当 0 用」
时,只把它用在"距最后一次回报多久"这一判据的**前置条件**上(`Some` 才谈超时)。实连 S15f 第一轮
读数照出洞:F2 账户 `logged_in=true`、`td_query_rejects=0`、`td_last_account_ms=None` 持续 390 秒
不变 —— 于是界面永远是「已装配·在线 + 资金回报尚未到手」。**徽标是绿的,查询却彻底停摆**,
这就是 F2 在界面上的形状。
- **修法与边界**:登录着的分支第三条判据 = 距 `td_last_login_ok_ms` 超过同一个 `ACCOUNT_STALE_SECS`
且资金时刻仍 `None` → `已装配·查询断供` + 「资金回报从未到手(登录已 Ns)」。刚登录几秒不算
(轮转第一拍本就要 2~6 秒);真到手即回在线。**不新建第二个阈值**:"多久算等太久"在两条判据里
必须是同一个数,否则同一件事有两种松紧。
- **为什么修在 ek-ui 而不是 ek-ctp**:判据原料(登录时刻 + 读数时刻)网关早就给了,缺的是消费侧
怎么用 `None`。网关侧加"资金超时"位就是把界面口径往上游搬(D-UI-STATUS-3 的反面)。
- **测试口径**:kittest 57 → 58 钉(`s60` 三段:5 秒 = 在线带话 / 40 秒从未到手 = 断供带时长 / 到手即回在线)。
## BD-UI-40(0.2.26,2026-10-22;第五套主题「Qt深色」= Python 版黑色主题复刻)
- **依据是读出来的,不是印象**:Python 版 `enkeep_trade/enkeep_trade/trader/ui/qt.py:102`
`qapp.setStyleSheet(qdarkstyle.load_stylesheet(qt_api="pyside6"))`;本机已装
**QDarkStyle 3.2.3**(`.venv/Lib/site-packages/qdarkstyle`)。色板取自 `dark/palette.py` +
`colorsystem.py` 原值,规格取自编译产物 `dark/darkstyle.qss`(2216 行实测:
`border-radius: 4px` 出现 48 次且是全主题唯一圆角值;描边一律 1px;padding 以 2px 为主)。
- **主题从四套变五套**,菜单钮一键循环加一站:`Qt灰白 → Qt深色 → 金融深色`。
**缺省仍是金融深色** —— 加主题不换缺省,现有 8 张快照一张没动(git 里只有 s42 是新增)。
- **`Theme as u8` 是 `set_theme`/`theme()` 之间的存储形式**,新变体必须留在枚举末尾、
且 `theme()` 的 match 必须加 `4 =>` 臂。**漏臂是这条链上最阴的一种错**:编译器不报、
`set_theme` 照存、读回来落进 `_ =>` 解回金融深色 —— 钮上文案换了而画面不换。挡它的是
`r9c`(五个判别值逐个钉死 + 每个定点值往返一遍)。
- **映射口径(逐条对过 qss)**:
- `window_fill = panel_fill = extreme_bg_color = #19232D`(Gray.B10)。qdarkstyle 所有容器
同底、靠 1px `#455364` 描边区分 —— 三个字段同值不是偷懒,是照抄。
- 按钮三态 = `#455364 → hover #54687A → pressed/checked #60798B`,且 qss 里
`QPushButton { border: none }` → `bg_stroke` 与 `bg_fill` 取同色(画出来就是无边)。
- `expansion` 一律 0.0:qss 的 hover 只换色不涨块,egui 缺省 hover/active 涨 1px。
- `fg_stroke` 宽度一律 1.0:Qt 不在 hover/pressed 加重文字,egui 缺省 1.5/2.0。
- `selection.bg_fill = #346792`(Blue.B40):qss 里 `QWidget::item:selected` 与
`QLineEdit:selected` 同值。**`selection.stroke.color` 被 egui 当选中项文字色用**
(style.rs:374)→ 取 TEXT1 `#DFE1E2`;照抄 qss 的 focus 边框 `#1A72BB` 会让选中 tab 文字变蓝。
- `weak_text_color = Some(#ACB1B6)`(Gray.B90)、`hyperlink_color = #259AE9`(Blue.B70)、
`faint_bg_color = #293544`(Gray.B20)、圆角全态 `CornerRadius::same(4)`。
- `interact_cursor = Some(PointingHand)`:Qt 按钮是手型光标,egui 缺省是浏览器口径。
- **已知偏离(做不到 / 决定不做,逐条给理由)**:
1. **选中 tab 那条 3px `#259AE9` 下划线**(qdarkstyle 的签名样式)—— egui `Visuals` 没有这个
概念,`selectable_label` 只能整块填充。做它要改 `render.rs` 里 8 处手绘,不在本轮范围。
本轮取「整块强调色」代替「灰蓝底 + 亮蓝下划线」。
2. **字号不动**:Python 版是全局 `QFont.pointSize(11)`(96dpi ≈14.7px),我们 body 16px。
字号在 `apply_ui_style_for` 里是跨主题的全局口径、不是主题属性,改它牵动其余四套与 8 张快照。
3. **禁用文本色 `#788D9C` 做不到**:egui 只调 `disabled_alpha`,不能指定具体灰蓝 → 保持 0.5
(那档亮度约 0.55,视觉近似)。
4. **菜单底色 `#37414F` 做不到**:egui 0.33 的 menu/popup 背景跟 `window_fill`,无独立字段。
5. **不移植图标资源**(qss 的 `rc/*.png` 箭头、树形 branch、窗口关闭钮、14×14 复选框图标)
—— egui 自绘,只映射颜色不映射形状。
6. QGroupBox 的 1px 描边 + 粗体标题、表格 gridline:egui 无对应物,靠
`noninteractive.bg_stroke = #455364` 近似。
- **有意不设的字段(防「多余」)**:`text_edit_bg_color`(缺省跟随 `extreme_bg_color`,同值设两遍
是重复)、`code_bg_color`(本 UI 的 `ui.monospace` 只换字体不带背景)、`warn/error_fg_color`
(语义色在渲染侧直绘、跨主题恒定 —— D-UI-34 那条口径不变)、slider/progress 相关(本 UI
0 处 slider、0 处进度条)。
- **测试口径**:ui_state 13 → 16 钉(`r9` 循环改五站 + `r9b` 色值钉 + `r9c` 编码钉 +
`r9d` 反证钉),kittest 58 → 60 钉(`s42_qdark_theme_full_panel` 快照 + `s61` **真实手势**钉:
不碰 `set_theme`,只点菜单钮,连点四下从缺省走到 Qt深色,每档都判「`shared.theme()` 换到位 +
钮文案跟着换 + 画面底色真的换」,再点一下判环闭合回缺省;`s42` 夹具与 `s21` 同源 ——
同一副内容换主题,快照差异只剩主题本身)。全绿 = 104 钉。
- **快照不只用眼睛看**:`target/theme-pixels.ps1` 把 s42 的 110 万像素数成直方图 ——
面板底 `#19232D` 占 **93.17%**、`#455364` 2.09%、`#293544` 2.17%、正文 `#DFE1E2` 2125 px、
选中块 `#346792` 748 px;反证两条同样硬:egui 原生深色底 `#1B1B1B` **0 px**、
金融深色底 `#18202C` **0 px**(出现即说明主题没切过去)。`#54687A`/`#60798B`/`#259AE9`
在静态快照里 0 px 是**预期**(静态图里没有 hover/pressed 控件、界面里没有超链接),
那三支只在 `r9b` 的 Visuals 层钉住。
- **实连巡回取证(真窗口,不是 kittest)**:`examples/opening_multi_tour.rs` 加了一个
`EK_TOUR_THEME` 开关(`qdark | finance | egui-dark | egui-light | qt-classic`,缺省不动;
`examples/` 不在 `cargo package` 清单里 → 不进发布包、不动 crate API)。2026-10-21 17:3x
用 7×24 前置(`EK_CTP_TD/MD` 指到 `EK_CTP_724_TD/MD`)跑一趟 `EK_TOUR_THEME=qdark`:
控制台首行「【开盘演示】主题 = Qt深色」;元账号 `ctp_9999_278153` 登录 = true、纯 TD
`ctp_9999_220380` 登录 = false(该账号在 7×24 前置未注册 —— 环境侧,与本轮改动无关);
交易状态推送流 129 行。窗口截图 `target/gui-qdark.png`(1416×939)同样过像素直方图:
`#19232D` 占 **83.96%**(余下主要是 OS 标题栏与边框)、`#293544` 3.65%、`#455364` 1.82%、
正文 `#DFE1E2` 2111 px、选中 tab `#346792` 707 px,`#1B1B1B` 与 `#18202C` 仍是 0。
截图里菜单钮读作「主题:Qt深色」,委托/成交两表是柜台真数据(6 笔委托 / 2 笔成交),
语义色没漂(LONG 红 / SHORT 绿)。
## BD-UI-41(0.2.27,2026-10-22;对照 Python 版逐项核出来的界面面:表头带 / 网格线 / 日志表格化 / 页签头与下划线)
起因是用户实看 ek-ui 真窗口后点出的五处,并要求「去 Python 版本再看,逐个对比,肯定还有别的细节」。
核对方法:两边都读代码,不凭印象 —— Python 侧读 `enkeep_trade/enkeep_trade/trader/ui/mainwindow.py`
(布局)+ `widget.py`(表格与监控控件)+ 已装 qdarkstyle 3.2.3 的 `dark/darkstyle.qss`(2216 行,样式)。
### 逐条核对结果(12 条,两边都有行号)
| 1 | 表头带底色 | qss:1946-1970 `QHeaderView::section { background #455364; color #DFE1E2; font-size 13px }` | 覆盖带填 `panel_fill` = 表体同色 → 等于没画 | **补**(本轮核心) |
| 2 | 表格外框 | qss:514-519 / 1875-1884 `border: 1px solid #455364` | 已有(`Frame::group` 描边 = `bg_stroke` `#455364`) | 不动 ✓ |
| 3 | 网格线 | 交易表 `widget.py:367 setShowGrid(False)`(注释「隐藏网格线」);**只有**合约表 `2423` / `2621` 没关 | 全无网格 | **只给合约表补** |
| 4 | 斑马纹 | `widget.py:359 setAlternatingRowColors(True)`,但 qss:515/1879 `alternate-background-color` = `#19232D` = 表体底 → 看不见 | 我们 `striped(true)` 硬开,奇行 `#293544`(实连截图里占 10.72%) | **收掉**,改跟 `visuals.striped` |
| 5 | 日志 | `LogMonitor`(`widget.py:1167-1180`)= 带「时间/信息/接口」表头的 QTableWidget,跟 Dock 伸展 | monospace 行 + `max_height(180.0)` 硬顶 | **换成 `pinned_table`** |
| 6 | 每格都有 tab 头 | 四个区全是 QTabWidget(`mainwindow.py:106/126/168/184`),单 tab 的「成交」「持仓」也有头 | 成交/持仓只是 `ui.label` | **补单 tab** |
| 7 | 选中 tab 下划线 | qss:1592 `:selected { background #54687A; border-bottom: 3px solid #259AE9 }` | 无(0.2.26 记为「做不到」) | **补** —— 那条已知偏离作废 |
| 8 | 未选中 tab 头底 | qss:1579 `QTabBar::tab:top { background #455364 }` + 1559 `!selected { border-bottom 2px }` | 无 | **补** |
| 9 | 服务目录表头 | 合约/账户类表都有 `setHorizontalHeaderLabels` | `svc_grid` 六列纯数据无头 | **补**(用户点名) |
| 10 | 表头字号方向 | 表头 13px < 正文 ≈14.7px(`QFont pointSize 11`,`qt.py:106-107`) | 表头 = 正文 = 16px(方向反了) | **表头改 Small 14** |
| 11 | 单元格 padding | `widget.py:369 item { padding: 4px 8px }` | `item_spacing (8,4)` | 相近,**不动** |
| 12 | 列集合 | 委托 16 列 / 行情按所分表 / 成交 12 列 | 委托 17(多「操作」)、行情 14 用 tab 分所 + 「今开」、成交号放第一列 | **有意不动**(功能差异,不是漏) |
### 落法与理由
- **`Chrome` 取色层**:qdarkstyle 有「表头带」和「选中 tab 下划线」两个概念,egui `Visuals`
里没有对应字段(它只有 `selection.bg_fill` 一根)。所以这层只能在渲染侧直绘。
取色**不新造颜色**,全部从各主题已声明的字段派生:`line = noninteractive.bg_stroke.color`、
`header_text = noninteractive.fg_stroke.color`、`tab_underline = hyperlink_color`、
`tab_fill`(Qt深色)= `hovered.bg_fill`。Qt深色那一列取出来正是 qss 原值
(`#455364` / `#DFE1E2` / `#54687A` / `#259AE9`),其余四套取自己的色板 —— 五套一起改,
因为只改 Qt深色等于没改用户日常看的那一套。
- **为什么文字必须重画**:epaint 0.33 没有 z-order(全 crate 搜过:无 `ZOrder` / `z_order` /
`Shape::bg_fill`),形状按插入顺序画 → 后画的带底会盖住先画的表头文字。所以
「盖底 → 画线 → 重画文字」是固定套路,`pinned_table` 覆盖带、`header_band`、`tab_label` 三处同一个理。
- **`header_band` 而不是 `pinned_table`**:账户清单 / 运维插件表 / 服务目录在**自动尺寸浮动窗**里。
0.2.17 实锤(s39):`pinned_table` = `auto_shrink(false)` 吃满整块可用高 + 覆盖带,
在这种窗里凑不出高度 → 表行与其后的口径句互相压字。这三张表行数个位数,不需要固定表头滚动面,
只要那根带。
- **网格线的画法**:`pinned_table_grid` 从 grid 实测的**列边界矩形**(表头格)推列线 x
(`h.max.x + item_spacing.x * 0.5`),从内容矩形 + 行数等分推行线 y。前提:每格都是 label
→ 行高一致(合约表正是如此)。所以网格线只给合约表,交易表不给(那边关了网格)。
- **日志时间戳口径**:`LogRing::push` 入环打 `chrono::Utc::now().with_timezone(+08)` 的
`%H:%M:%S`,与 `fmt_dt` 同一口径 —— `beijing_tz` 因此从 render 提到 state,一份实现两处用。
本机不是 +08 时口径错 = 日志与成交表差几小时,交易员对不上账(钉:
`log_entries_stamp_beijing_wall_clock`)。
- **`snapshot()` 门面不动**:`LogRing` 内部从 `VecDeque<String>` 换成 `VecDeque<LogEntry>`,
但 `snapshot()` 仍返回正文串 —— 30 处既有调用点(测试 / 巡回取证 / demo)一行没改。
渲染侧另开 `entries()` / `logs_entries()`。两个口各有真实消费者,不是重复。
### 已知偏离(记死,不藏)
1. **日志只有两列**(时间/信息),Python 有第三列「接口」:我们的 `log_line` 调用点不带网关,
正文自带 `[ops]` / `[订阅]` 这类前缀。硬造一列空列不如不造。
2. **日志不自动跟到最后一行**(`stick_to_bottom` 撤除):Python 的 QTableWidget 也不自动滚到底。
3. **斑马纹**:Python 的意图是开(`setAlternatingRowColors(True)`),但 qss 让它不可见。
我们跟「不可见」这一结果。**这一条我先前给过相反判断**(一度主张保留斑马帮读 17 列宽表),
最终按「与 Python 一致」定;若日后要开,应开在主题层(`visuals.striped`)而不是表里硬开。
4. **字号**:全局 Body 16 / Small 14 vs Python 全局 `pointSize 11` ≈14.7px。字号是跨主题全局口径,
本轮只把「表头比正文小」这个**方向**修对,没有动全局字号。
5. **列集合**(表 12 行):功能差异不是漏。
### 审计(遗漏 / 错误 / 重复 / 多余 / 逻辑)
- **改了什么、删了什么,逐条可查**:删除的只有 `group_frame`(四格全走 `group_frame_plain` 后无使用者)
和 render 侧那份 `beijing_tz`(搬到 state,render 改 import)。**没有删任何功能面**:
日志钮、溢出提示句、清空钮、`snapshot()` 门面、`log_grid` 之外的所有表容器都在。
- **发现并修掉的自己的错**(都是本轮内自查抓到,不是门禁抓的):
① 合约表两处调用点我先把**空态分支**换成了 `pinned_table_grid(…, rows.len(), …)`(空表画网格),
下一眼读回时看到并改回;② `Chrome` 字段用了裸 `Color32` —— `use egui::Color32` 是
`theme_visuals` 函数内的局部 import,结构体在函数外看不见,编译报 E0425;
③ `Painter::line` 在 0.33 收 `Vec<Pos2>` 不收元组 → 改 `vline` / `hline`;
④ `Grid::show` 返回 `InnerResponse`,`gr.rect` 不存在 → `gr.response.rect`;
⑤ 新测试里「时间」「服务」「实例」都不是唯一标签(行情带也有「时间」列、运维窗也有「服务」标题、
服务目录有三对「实例」头)→ 改 `query_all_by_label` 数个数,顺带把断言变强。
- **重复**:一度想给日志表加「时间列 + 正文列 + 接口列」三列,与「调用点不带网关」冲突 → 砍成两列。
`snap` / `snap_budget` 两个快照入口是**必要**的两条:一条 0 容差,一条带墙钟预算,共用同一把锁。
- **多余**:`group_frame` 删掉;`Chrome` 里一度准备放 `tab_head_stroke` 与 `tab_underline_width`
两个字段,前者与 `line` 同值、后者只有 2/3 两个常量 → 都不进结构体,写在使用处。
- **逻辑问题(本轮最容易再犯的一种)**:带墙钟时间列的快照天然不确定。第一反应是「调容差」,
真正的处理是把**结构面**钉在 AccessKit 标签上(确定性),把**像素图**降级成带预算的辅助证据,
并在 `snap_budget` 的注释里写清为什么这三张要预算。
- **没动的**(有意):`snapshot_lock()` 旧调用形式(9 处)只在新钉里换成 `snap_guard` ——
改全部旧站点属于另一轮清理,本轮不夹带。
### 证据
- 门禁:`scripts/gate.ps1` 全绿(fmt / test 111 钉 / clippy `-D warnings` 0 / doc 0 / check-package strict)。
- 快照:9 张重生成 + 新增 `s63_logs_table_headers`、`s65_ops_service_headers`;
肉眼核过 `s42`(Qt深色)与 `s21`(金融深色):六张表都有表头带,委托/未成交、资金/日志、
行情带交易所、成交、持仓五处都有 tab 头,选中 tab 有下划线,画面无斑马。
- 像素直方图(`target/theme-pixels.ps1`,本轮加两条硬检查):`#259AE9 ≥ 1`(选中 tab 下划线,
改前 0)与 `#54687A ≥ 200`(选中 tab 底,改前只在 hover 时出现 = 0)。
- 真窗口:`EK_TOUR_THEME=qdark` 实连 7×24 前置一趟,截图过同一套像素检查(见本轮报告)。
## BD-UI-42(0.2.28,2026-10-22;表头带与网格线的几何根因:`Response::rect` 是控件矩形,不是列框)
用户实看:「运维那个表格表头还是有问题的,表头背景没有按这个表格整行来,最后一列感觉好像只有
文字有背景,其他没有文字,导致好像断开一样」+「资金账户那个表格怎么又有问题了,怎么老是有不同的
表格有各种问题」。这次不逐张表打补丁,而是把「带和线按什么算宽度」这一条查到底。
### 十张表全核一遍(先列清楚有哪十张)
| 行情带 `tick_grid` | `pinned_table` | ScrollArea `inner_rect` ± `clip_rect_margin` | 本来就对 ✓ |
| 委托 `order_grid` / 未成交 `active_grid` | `pinned_table` | 同上 | 对 ✓ |
| 成交 `trade_grid` | `pinned_table` | 同上 | 对 ✓ |
| 持仓 `pos_grid` | `pinned_table` | 同上 | 对 ✓ |
| 日志 `log_grid` | `pinned_table` | 同上 | 对 ✓ |
| 查询合约 `contract_grid` | `pinned_table_grid` | 同上 | 带对;**列线错** → 修 |
| 资金格 `acct_grid` | `header_band` | 表头文字矩形并集 | **错** → 修 |
| 运维插件表 `plug_grid` | `header_band` | 同上 | **错**(用户点名的那张)→ 修 |
| 服务目录 `svc_grid` | `header_band` | 同上 | **错** → 修 |
| 账户清单 `account_grid` | `header_band` | 同上 | **错** → 修 |
(下单面板 `order_form_grid` 是表单不是表,无表头行,不在这条上。)
### 根因只有一条
`egui::Response::rect` 是**控件自己的**矩形,不是列框。Grid 的路径:
`GridLayout::next_cell(cursor, child_size)` 给出列框 = `child_size.max(prev_col_width, prev_row_height)`
(grid.rs:180-185),随后 `align_size_within_rect` 按 `LEFT_CENTER` 对齐,**返回子控件尺寸**
(grid.rs:188-193)。所以一个表头 label 的 `rect.max.x` = 表头文字的右端,不是列的右边界。
0.2.27 我在列线那行写的注释是「取表头格的列边界(列宽由 grid 量出来,唯一权威源)」——
**这句话是错的**,而错的地方正是新代码唯一依赖的那个量。本轮更正注释也更正算法。
两个后果:
1. `header_band` 的带 = 表头文字矩形的并集 → 带只到最后一列表头文字的右端。运维窗『操作』列
宽 = 两个按钮(~200px),表头「操作」只有 ~40px → 列里 160px 没有带;服务目录最后一列
「实例」为空时同理。用户看到的「最后一列只有文字有背景 / 断开一截」就是这个。
2. `pinned_table_grid` 的列线 = 本列表头文字右边 + 半间距 → 表头比数据窄时(「名称」2 字 vs
「螺纹钢2605」)列线画在列里面,不是列之间。
### 两条改法
- **带铺整行**:`header_band` 的横向范围改 `ui.max_rect()`(这一格 / 这一窗的可用宽)。
与 `pinned_table` 那条带同一口径(那条铺 ScrollArea `inner_rect` = 整格宽),也与 Python 一致
(`QHeaderView` 铺满整个表视口,最后一节 stretch)。
- **列线取下一列的左边界**:每列的格都左对齐在列起点,所以「下一列表头/数据格的 `min.x`」
就是列边界,往回让半个 `item_spacing.x` 即列线。这个量不依赖表头文字宽度。
### 新钉与「判据到底抓不抓得住」
- `s69_ops_window_header_band_covers_the_row`:像素判据 = 带的最宽连续段 ≥ 体表最宽一行的
文字跨度。两个坑都踩过并写进注释:① 带里有表头文字把连续段打断,要取带**底**几行;
② 格子的外框线也是带色(1px 横跨整格),单看最长段会把框线当成带(s70 第一版就是这么
「通过」的)→ 加「带必须有厚度」(上下各 3 行的带色像素数不低于本行一半)。
- **判据有效性是实测过的**:把 `header_band` 换回旧算法重跑 → s69 直接红,带 **375 px**
(修后 720 px)。不是写完就信的钉。
- **诚实的一条**:s70 的「带 ≥ 体表」断言在夹具里新旧都过 —— 资金表只有五列、列宽由数据
撑开、内容几乎铺满整格,夹具里带和体表本来就差不多宽。用户在真窗口看到的资金带短一截
(最大化时带 336 px / 整格 450 px)由 s70 的**快照**抓(旧算法下差 627 px)。
教训:「带必须盖住体表」和「带必须铺满整格」是两条不同的判据,夹具只抓得住前者里
内容真的比格子窄的那些表。
- `s71_contract_table_gridlines_land_on_column_bounds`:合约表是全仓唯一开网格线的表,
原来没有任何带真行的快照核它。补一张(三行真数据,含「精对苯二甲酸709」这种名字明显
比表头宽的列),肉眼核过列线落在列与列之间。
### 顺带修的一处潜在随机红
wgpu 后端钉(Windows 缺省 Vulkan,避开本机那张 "GameViewer Virtual Display Adapter" 的坏回读)
原来埋在 `snap()` 里。只用 `harness.render()` 的像素钉(s67/s68)靠"同二进制里恰好有别的
测试先跑过 snap"才拿到 Vulkan —— 测试线程顺序不保证 = 随机红。本轮 s69/s70 单独跑就直接
炸在 wgpu 里,证明这条不是假想。提出为 `ensure_wgpu_backend()`,`snap` / `snap_budget` /
四个 render 钉各自先钉(OnceLock 保证只写一次且先于任何 wgpu 实例)。
### 审计
- 本轮改动面:`header_band` 的横向范围、`pinned_table_grid` 的列线 x、测试侧
`ensure_wgpu_backend` 提取 + 三个新钉。**没有删任何功能面**,没有动主题、没有动依赖。
- 0.2.27 的 CHANGELOG/DESIGN 里那句「列宽由 grid 量出来」保留在原条目里不回改
(历史条目不改写),但代码注释已更正,并在本节写明它当时是错的。
- 遗留(有意不动):带铺 `ui.max_rect()` 后,浮动窗里两张宽度不同的表(插件表 / 服务目录)
带都铺满窗宽 —— 与 Python「表头铺满视口」一致,但如果日后要让窗内两张表各自贴合自己的
列宽,应改成传 grid 的 `response.rect`。本轮按 Python 口径统一,不混两种。
## 0.2.29 — Wave A:数据监控表换 `egui_extras::TableBuilder`(BD-UI-43 / BD-UI-44)
### 为什么换(不是"换个库好看一点")
交接报告 §4 实拍八条毛病里,**2 列宽未按内容 / 3 数字不成列 / 5 成交表被右缘切掉且无横向滚动
提示 / 6 买5卖5 贴左缘 / 8 行线切进行里** —— 五条的根因是同一个:**表是我们自己拼的**。
`Grid` 没有"表头"概念(表头只是第一行),固定表头只能靠 painter 覆盖层;列宽由 Grid 在
**首帧**按当时可用宽量一次,之后内容变宽不回头;数字列没有对齐语义;行线是我们按 `row_h`
手算的、与真实行矩形无关 → 必然切进行里(0.2.29 第 1 步的 T1 钉在旧代码上实测:13 根线落在
y=423/444/…/674,文字中心在 424/445/466 → 判据红,证明判据抓得住这个缺陷)。
换 `egui_extras::TableBuilder`(0.33.3,与 egui 同版,emilk 官方维护)一次解决四条:
列宽按内容(两趟 sizing,`max_used_widths` 跨帧保留)、表头天然在体表滚动区之外
(`Table::body` 自己开 `ScrollArea::new([false, vscroll])`,table.rs:746 → **固定表头是库的行为**,
不是我们的覆盖层)、`striped` 跟 `visuals`(table.rs:482)、每格矩形由库给
(`TableRow::col` 返回 `(Rect, Response)`)。
### 换表过程中撞到的三条 egui 事实(写下来免得下次再撞)
1. **一次点击只发给一个 `Response`。** `context.rs:1384`
`let clicked = Some(id) == viewport.interact_widgets.clicked;` —— 整个 viewport 只有一个
`Flags::CLICKED`;AccessKit 那条路(`context.rs:1344-1350`)也只给被点的控件。于是
「整格可点」(`TableBuilder::sense`)与「格里的钮 / 勾选框」不会同时拿到 `clicked`。
换表第一轮直接把 5 个钉打红(s8 单击带入代码 / s11 双击撤单 / s23 行首格选中 / s18 / s29)。
修法:`tcell` 带回**两个**响应(格响应 + 格内容自己的响应),调用点把两者 OR 起来
(`CellOutcome` 把 `Response` / `()` / `InnerResponse<R>` 三种格返回值统一掉)。
2. **`Column::auto()` 不会为了"挤得下"而缩列。** 12 列成交表在 ~450 px 的格子里列总宽 ~800 px,
库不缩(实测表头节点右缘到 x=1608,画布只有 1280)→ 溢出是真的,必须有一层横向 ScrollArea
才谈得上"最后一列可达"。
3. **egui 的滚动条缺省是看不见的。** `scroll_area.rs:1418-1425`:把手色 =
`scroll.foreground_color ? visuals.fg_stroke.color : visuals.bg_fill`,缺省
`foreground_color = false` → 把手色 = 体表色,**条画了但和体表同色**。实拍两张
(`tests/snapshots/s21_full_tables.png` 与 `target/chk-0229-frame.png`)都确认"列被右缘切掉、
但没有任何横向滚动提示" = 毛病 5 只治了一半。修法:`pinned_table_impl` 里把
`spacing.scroll` 显式写死 `floating=false` / `foreground_color=true` / `bar_width=10.0`
(Qt 口径:滚动条占位、高对比把手)。改完 s21 里三张表的横向条肉眼可见。
### 边界:哪些表**不**换(写死,免得日后当成"没换干净")
- **换**(固定表头 + 会溢出 + 有行交互):行情 / 委托 / 成交 / 持仓 / 日志 / 查询合约 / **资金**。
资金表原来在"浮动窗短表"那一类里,实测它和日志表共用同一块 tab 容器(不是浮动窗)→ 并入。
- **不换**(自动尺寸浮动窗里的短表,0.2.17 实锤 s39):运维插件表 / 服务目录 / 账户清单 /
下单表单(`order_form_grid` 是表单不是表)。这类用 `Grid` + `header_band`。
`pinned_table` = `auto_shrink(false)` 吃满整块可用高,在自动尺寸浮动窗里凑不出正确高度 →
表行与其后的口径句互相压字。
- 列分隔线 + 可拖列宽只给合约表(`GridLines::Grid` → `resizable(true)`),照 Python
`setShowGrid(False)` + 合约窗可拖列的口径;其余表只有行线。
### T7 行数上限实测(本机,同一夹具,5 次样本里的最优单帧建构)
| 50 | 11.98 → 29.4 → 12.2 ms | 1.32 → 2.16 ms |
| 200 | 42.80 → 105.2 ms | 4.96 → 8.33 ms |
| 1000 | 208.14 → 512.1 → 214.7 ms | 26.44 → **47.60 ms** |
判据「1000 行单帧 < 60 ms」说的是**实窗 = release 构建** → **成立**(两轮 26.44 / 47.60 ms),
不换 egui_table;退路(egui_table 0.7.0 虚拟滚动)保留在方案 §8.3,不启用。
**但这条不能当墙钟钉 —— 本轮实测逼出来的结论。** 同一 dev 判据在同一台机器上摆动 2–4 倍
(50 行 11.98 / 29.4 / 12.2 ms;1000 行 208 / 512 / 215 ms)。原因不是表格层,是夹具环境:
`cargo test` 里 75 个测试并发跑,快照钉那批在做 wgpu 渲染,墙钟样本被抢;而快照钉共享
SNAP_LOCK,一个墙钟红还会连带一片 `PoisonError`。我先按「规模比 ≈ 成本比」改过一版
(线性期望 4 / 5,上限 8),门禁里照样红了一次(实测比值 8.6,而同一轮 1000 行只用 215 ms
—— 比值被 50 行那档的小样本噪声放大)。**结论:这台机器上墙钟阈值只能当手工实测记录,
不能当 CI 钉。** `t7_…` 最终钉的是两件事:① 每 n 行都被真实建构出来(`query_all_by_label`
计数 = n,确定性、无噪声 —— 这才是「TableBuilder 没有虚拟滚动」的真正退路警报);
② 一条数量级上限(dev 3000 / release 300 ms)只抓病态爆炸。性能结论靠手工实测记录在这里,
要重测就 `cargo test --test ui_kittest t7_table -- --nocapture` 单独跑。
### 本轮新钉(118 + 4 − 1 = 121 钉,15 张快照)
`t2_column_width_follows_content`(同一张合约表喂短名/长名,下一列表头左边界必须右移 > 20 px,
且长名文字不得越过那条列线)、`t3_numeric_columns_are_right_aligned`(成交表价格列 + 行情带盘口(买1价 / 卖1价)每列三行数字右边界
差 ≤ 1 px,判据走 AccessKit 节点矩形不靠像素)、`t4_trades_table_scrolls_horizontally`
(12 列 + 长名称 → 滚一下首列表头必须横移 > 100 px、y 不动 = 固定表头不回归、滚动后「接口」
列右缘必须落进可视区)、`t5_row_backgrounds_do_not_alternate_under_qdark`(Qt深色相邻行底色
相同且 = 主题体表色 #19232D,证明斑马没被硬开、底色没硬编码)、`t7_table_builder_row_count_benchmark`
(见上一节:钉「每行都被建构」+ 数量级上限)、`t8_book_frame_price_and_volume_columns_are_aligned`
(下单侧五档盘口十行:价列与量列右边界差 ≤ 1 px、档位标签同一列;判据有效性实证 = stash 掉
代码改动后红在「价格格必须全被抓到」)。
顺带记一条 harness 事实:**滚轮事件只受理"指针在体表/空白处"的位置**,悬停在表头格上单发一次
滚轮不动(实测 x 不变);连发两次(真实用户滚轮天然是多拍)才受理。判据按这个形状写。
### 尾巴:五档盘口(BD-UI-45)—— 毛病 6 的本体在下单侧,不在行情表
上一轮审计我把「毛病 6 有判据了」记在行情表的买1价/卖1价列上,**位置错了**:用户实拍的
「买5/卖5 不对齐贴左缘」是下单侧的常驻五档盘口 `render_book_frame`。它原来是一支文本
`ui.label(format!("卖{i} {:.1} {:.0}", p, v))` —— 价与量靠**空格**对齐,位数一变(101.5 →
12345.5)整块错位。换成 `TableBuilder` 三列(档 / 价 / 量),价与量右对齐。
三条边界写在这里:
- **不用 `pinned_table`**:下单侧是窄 `SidePanel` 且外层已有 `ScrollArea`,`pinned_table` 的
`auto_shrink([false, false])` 会吃满整块可用高(0.2.17 s39 那条教训的同型),这里用缺省
`auto_shrink([true, true])` 贴合内容。
- **不加表头**:Python 侧没有五档盘口这个块(`widget.py` 只有行情表的买1价/买1量/卖1价/卖1量
四列 + 「点击买列填充买1价」联动),这是我们自加的常驻盘口,保持无表头。
- **买/卖色仍是硬编码**,集中成 `BOOK_ASK_COLOR` / `BOOK_BID_COLOR` 两支常量,Wave B 令牌层接管。
`t8` 的判据形状值得记:同一个 tick 同时喂行情带表格与下单侧盘口 → 同名标签两处。判据按
**夹具里的 y 分区**只取盘口(`rect.min.y > 500.0`),夹具里行情带在顶部(< 300 px)、下单表单
字段在 500 px 以上才到盘口。
---
## 0.2.30 — Wave B:设计令牌 + 只留一套主题(BD-UI-46 / BD-UI-47)
**BD-UI-46(颜色 / 字号 / 间距只有一个来源)**:新增 `src/tokens.rs`。`qdark` 支是 qdarkstyle 3.2.3
qss 的原值(BG1 `#19232D` / BG2 `#293544` / BG4 `#455364` / BG5 `#54687A` / BG6 `#60798B` /
TEXT1 `#DFE1E2` / TEXT3 `#ACB1B6` / ACCENT2 `#346792` / ACCENT4 `#259AE9`);`semantic` 支是**用途**色
(涨 `#E05050` / 跌 `#2E9E2E` / 警 / 错 / 成 / 信 / 弱)。口径:**涨跌色与对错色不许混同**(U3 钉),
**源码里除 `tokens.rs` 外不得再出现 `Color32::from_rgb`**(U2 机械钉扫源码)。
egui-charts 0.2.0 的 `ChartConfig::default()` 读它自己的 RON 设计令牌(青涨红跌),**不受 egui
`Visuals` 影响** → K 线必须 `Chart::with_config` 显式覆盖,否则「红涨绿跌」在图上反着画。
**BD-UI-47(只有一套长相)**:用户裁定只留 Qt深色。`Theme` 枚举 / `theme()/set_theme()/cycle_theme()` /
`apply_ui_style_for` / 菜单主题钮 / `EK_TOUR_THEME` 一并删除 —— 公开面真删,不是留着不用。
配套新增 `src/ui_ext.rs` 五支(`section_title` / `muted_label` / `space_group` / `primary_button` /
`num_cell`),**每支都要查到调用点**(U4)。浮动窗才用 `section_title`;四张主监控格仍然遵守
BD-UI-31「表题文字撤除,带位即身份」。
**几何教训(本轮实测)**:`Frame::group` 的描边按**内容**矩形画(`frame.rs:486` 用 `content_ui.min_rect()`),
要「预留多大就画多大」必须自己 `allocate_exact_size` + `painter().rect(..., StrokeKind::Middle)`。
`table_heights` 给的是**请求**行高,TableBuilder 实际行高 = max(请求, 格内最高控件):两者不等时
自绘行线整体错位 —— 这就是 0.2.28 治过的毛病 8 的复发路径,行高必须 `max(文字+6, INTERACT_HEIGHT)`。
**已知遗留(交接报告 §7.1 / §13)**:浮动窗自动尺寸导致表头带比表体短(实测 84 px,s69 钉住 ≤ 90);
合约窗视口裁行导致行线计数判据降级(s72 ≥ 10 根,间距与「不压文字中心」仍然严格)。
---
## 0.3.0 — Wave C:下带数据区换 egui_dock(BD-UI-48)
**BD-UI-48(数据区交给停靠框架,框架形状不变)**:菜单条 / 行情带 / 下单侧**不进 dock**,仍是
egui 原生面板;只有下带右侧四组(委托 / 成交 / 持仓 / 资金)换成 `DockState<DataGroup>` 树,
在 `CentralPanel` 里 `DockArea::show_inside`(`DockArea::show` 与 `CentralPanel::show` 互斥)。
格内容仍由 0.2.29 的四个 group 渲染函数出,**dock 只换容器不换内容** —— 这条边界让 Wave A/B 的
表格判据一行都不用改。
**为什么先 spike**:§3 明写「kittest 能否定位 dock 的 tab 按钮 / 分隔条」未实测,而这一条决定
这一波所有判据钉写不写得出来。spike(commit `52104cd`)六条全过才落地;退回路径是撤依赖 +
回滚 `Cargo.lock`,Wave A/B 成果不受影响。
**spike 换来的三条硬口径**
1. dock 的 tab 节点在 AccessKit 里查得到,但**精确标签查不到**:`query_all_by_label("委托")` = 0,
`contains("委托")` = 1(标签比标题长)。判据钉一律 `contains`。
2. `DockState::new(vec![..])` 把所有 tab 塞进**同一个叶子**;要 2×2 必须显式 `split`
(`Right 0.5` + 两半各 `Below 0.5`)。不 split 时四组挤成一列 —— S4 直接红。
3. kittest 里 `SidePanel::right().default_width(300)` 没吃到 300(实测侧宽 ≈ 96)→
判据不许假设「1280 − 300」,中央范围必须从实测推。
**判据形状的选择**:V2 钉的是**树**(4 叶子 + 3 分隔节点 + 每刀 `fraction == 0.5`),不是像素。
原因:渲染侧「委托 / 资金」这些标签在 dock tab 与格内二级 tab 上都会出现,拿标签矩形当列边界会
量到错的节点;渲染侧的 50:50 由快照 `s3 / s42` 钉住。这是**口径降级还是口径更正,要写清**:
Wave B 的 U6 钉的是「我们自绘的四格边框等宽」,那个容器已随 `group_frame_plain` 退役 ——
U6 删除、V2 接替,不是把 U6 放宽。
**样式纪律**:dock 的 `Style` 只走 `Style::from_egui(ui.style())`(吃 `tokens::apply` 填好的
egui `Style`),`dock.rs` 里不得出现 `from_rgb` / `Color32::`(V5 机械扫)。
---
## 0.3.1 — Wave D(部分):排序 / 双击 / 格式定死(BD-UI-49)
**BD-UI-49(展示格式与排序都收进一层,不散在各表)**:数字与时间格式只有 `src/format.rs` 一份口径;
排序键只有 `UiShared::table_sorts` 一份,而且**排序只作用在喂表前的快照副本**(`sorted_rows`)上,
数据模型不被界面排序改写。这条边界就是 Python `widget.py:396-419`「更新途中重排会错位」那个病的正解。
**时区口径(动手前先定死,Wave D 测试方案 §0)**:表格时间列 = 北京 +08 墙钟,与日志同源;
K 线钟面 = UTC 重标定,不动。判据一律喂固定 epoch,不读当前时间。
**本轮实撞的三条 egui/kittest 口径(写下来免得后人重踩)**
1. `egui_extras` 的 `.sense(Sense::click())` **不吃表头格** —— 表头格只有 hover 感。
先试「格内 `interact`」失败,正解是**表建完后在同一层(横向 ScrollArea 的内层 ui)补 `interact`**;
格内的 ui 被 TableBuilder 借走,而且坐标必须落在同一层里才对得上。
2. kittest 的 `Node::click()` 走 AccessKit 动作,**静态文本节点没有动作** —— 点了等于没点。
判据要的是真实输入事件:`Harness::input_mut()`(0.33.3 里不带闭包,直接返回 `&mut RawInput`)
往同一拍事件队列塞 `egui::Event::PointerButton`(0.33 不叫 `InputEvent`)。
3. 判据挑节点**不能只按 y 筛**:成交表与委托表的表头带在同一行,只按 y 筛会挑到委托表的「价格」
(实锤:点击落在 x=746,而成交表的「价格」格在 x≈1200)。必须同时按 x 筛。
**没做**:`#[ignore]` 两条 —— W5(右键菜单代码在,判据没定对形状)、W7(退出确认没实现)。
理由字符串写在测试上。这是 Wave D 的欠账,不是已完成。
**Wave D2 补的两条(同版本 0.3.1,提交 `d2a641e`)**
- **退出确认走 egui 0.33 的 viewport 关闭协议,不走宿主**:`ctx.input(|i| i.viewport().close_requested())`
为真且未放行 → `ViewportCommand::CancelClose` + 弹确认窗;放行位 `exit_confirmed` 置起后重发
`ViewportCommand::Close`。这条完全住在 ek-ui 里,**没动 ek-station / 宿主一行** ——
本来以为要在 `main.rs` / `runtime.rs` 挂 `on_close_event`,那是 Qt 的思路,不是 egui 的。
- **判据形状要跟着机制走**:kittest 没有「点窗口关闭按钮」这个动作,所以 W7 钉的是机制形状
(拦关闭 + 放行路 + 状态归属)。实窗证据(0.3.1 当时):优雅关闭(taskkill)后进程存活 +
确认窗在场。0.3.2 起「看确认窗长什么样」这条改为**实跑行为核**:启动 → 请求关窗 →
进程不退(优雅 `taskkill` 后仍在),不出图。
- **右键菜单的教训(挂起前定性的东西)**:同一个二级点击,普通按钮的 `context_menu` 在 kittest 里查得到,
体表格的格查不到 —— 差别在**控件有没有 AccessKit 动作**,不在事件本身。
以后要钉右键菜单,得给菜单一个真控件(按钮/图标),或者退到源码扫描;别拿静态文本格硬试。
---
## 0.3.2 口径变更:截图 / 读图 / 像素快照全部撤除(交接报告 §17,本轮定死)
**为什么**:这台机器上的模型没有可视能力 —— 既截不了图,也读不了图。原来靠「看一眼」和
「数像素」立起来的判据,在这一轮之后没有任何人能复核;`tests/snapshots/` 里 15 张 `.png`
基线本身成了不可读的死数据。裁定:**判据一律改成代码能自己判的**,计划 / 测试 / 文档里
不再出现「截图、读图、像素扫描、前后对照图」。
**四类判据(取代「快照 + 像素」)**
1. **语义树**:AccessKit 节点在不在、标签是什么、`Node::rect()` 几何(列对齐、行位置、贴边、滚动)。
2. **状态层**:`UiShared` 字段与访问器,纯单测(不建构界面也判得出来)。
3. **源码机械扫描**:`from_rgb` 只许出现在 `tokens`;开关必须显式(`striped` 透传、
`show_leaf_close_all_buttons(false)`);格式只走 `src/format.rs`。
4. **本帧绘制指令**:`harness.output().shapes` → `Vec<epaint::ClippedShape>` ——
immediate 模式这一帧的真实绘制指令,确定性、可比、不需要眼睛。
**第 4 类实核出来的口径(本轮踩出来的,写下来免得后人重踩)**
- 图层形状也在 `FullOutput.shapes` 里:`egui/context.rs:2526` 把根 viewport **所有层**
(`viewport.graphics.drain(..)`)并进来 → toast 画在 `Order::Foreground` 层也钉得住(e1–e4)。
- `ClippedShape.clip_rect` 分得开「画了」与「被裁掉了」:`TextShape::visual_bounding_rect()`
与 `clip_rect` 求交,交为空 = 这一帧没露出(s27「数据行不许从表头带上方露出」靠这个)。
- **坑 1**:独立 OS 窗(`show_viewport_immediate` 的 Immediate 面)有自己的 FullOutput,
根 output 里没有它 → 这类判据退回状态层 + 源码扫描(s10 就是这么改的)。
kittest 里四窗走 Embedded 回退(主窗内 `egui::Window`),形状在根 output 里。
- **坑 2**:同一支 BG4 在画面里到处都是(主窗各表的带底横线、dock 未选中 tab 的 2px 下边线、
侧栏分隔竖线)。判「这张表的线」必须先用**这张表自己的带**定 x 闸:带 = 盖住该表表头格
(`Node::rect()`)的 BG4 实心矩形;横线还要求横跨带宽的 80%。硬编码「窗在画面哪一块」的
老区域在 0.3.0 换 dock 容器后已经错位(s70 第一版就是这么红的)。
- **坑 3**:`query_by_label` 遇同名标签直接 panic(主窗表与合约表共用「交易所 / 名称」)。
跨表挑格一律 `query_all_by_label` + 按全仓唯一锚点标签(「最小变动」)的 y 筛。
- **坑 4**:`Rect` 没有 `is_empty()`,只有 `is_positive()`;`Rangef` 不从 `RangeInclusive`
自动推(要 `Rangef::new`);`f32` 不 `Ord`(`.max()` 换成 `fold(f32::max)`)。
**测试里的取数 helper**(`tests/ui_kittest.rs`)
| `shapes_of` / `clipped_shapes_of` | 本帧形状 / 形状 + 各自裁剪矩形 |
| `filled_rects(color)` | 该色实心矩形(`rect_filled` = fill 同色且描边为空) |
| `header_bands(color, min_w)` | 实心矩形里够宽够高的(高 ≥ 8 px,1px 框线不算带) |
| `hlines(color)` | 该色水平线段 `(y, x 范围)` |
| `texts_containing(needle)` | 文字形状 `(整串文本, 视觉矩形, 实际取色)` |
| `text_color_of(&TextShape)` | `override_text_color` → 第一个非 PLACEHOLDER 的 section 色 → `fallback_color` |
| `text_span_in(region)` | 区域内真被画出来(裁剪后非空)的文字最宽跨度 = 体表宽度 |
| `toast_texts` / `toast_marks` | 右下角(x ≥ 900、y ≥ 500)的 toast 正文 / 「▎」级别标记 |
**逐条改判(原来 → 现在)**
| s3 | 首帧快照 | AccessKit 几何钉保留 + BG4 实心矩形(宽 > 100)在场 |
| s10 | K 线窗快照 | 表头标签钉 + `kline_symbols()` 状态钉 + `render.rs` 里 ChartConfig 四色源码扫描 |
| s16 | CJK 快照 | 字体挂在 Proportional / Monospace 两族 + 语义树查到中文串 + 这一帧真排出 > 300 pt 宽度 |
| s21 | 满表快照 | 数据格在场 + 三张表列名逐列计数 + 行数按委托号/成交号计数(CTP_7701 两处 = 委托行 + 成交行的委托号格) |
| s27 | 滚动后快照 + 闸外像素 | 表头 y 不动 + 带矩形 + 真画出来的行文字(裁剪后)必须在带底之下 |
| s39 / s40 / s41 | 三张弹窗快照 | 纯语义树计数(测试名也去掉 `_snapshot`) |
| s42 | 整面板快照 | `ctx.style().visuals == tokens::visuals()` + BG1 大面积底 + BG4 整行横线 |
| s63 / s65 | 快照 | 表头标签计数 |
| s67 | 数 `#455364` / `#259AE9` / `#54687A` 像素 | 绘制指令:BG4 带面积 > 4000、ACCENT4 横线 ≥ 1、BG5 tab 底 ≥ 1 |
| s69 / s70 | `band_vs_body` 像素 + 硬编码窗口区域 | 带 = 盖住表头格的 BG4 实心矩形;体表 = 带下方带内真画出来的文字跨度(缺口 ≤ 90 / 带 ≥ 体表) |
| s71 | 带真行的快照 | 竖线线段(egui_extras 列边界,`table.rs:907`)必须落在相邻两列内容之间,或落在最后一列与表右缘之间 |
| s72 | `scan_hlines` 像素扫 + 快照 | `hlines()` 取带内 BG4 水平线段:≥ 10 根、间距均匀、每根在两行之间、离行文字中心 ≥ 5 px |
| t5 | 每行右侧取一个像素 | `visuals.striped == false` + 源码扫描(透传 `.striped(striped)`、无 `.striped(true)`)+ 整帧 0 块 BG2 斑马 |
| t8 | 快照 | 纯 AccessKit 右边界对齐(价列 / 量列 / 档位标签) |
| u3b | 矩形内数红 / 绿像素 | 文字形状实际取色 == RISE / FALL + `rise_fall()` 函数钉 + 源码扫描 |
| e1–e4 | 两次渲染数右下角色带 | toast 正文与「▎」标记的文字形状:内容 + 实际取色 + 位置 + y 序 + 条数 ≤ `SCREEN_CAP` |
| e5 / W7 / V4 / V5 | 已经是源码扫描 | 判据不动,措辞里的「截图」删掉 |
**删掉的东西**:`SNAP_LOCK` / `ensure_wgpu_backend` / `snap` / `snap_budget` / `snap_guard` /
`count_near` / `band_vs_body` / `scan_hlines` / `Shot` + `shot_of` / `ek_px` / `ek_near` /
`ek_bands` / `EK_*_RGB`;`tests/snapshots/` 15 张基线全删;`Cargo.toml` 里 `egui_kittest` 的
`features = ["snapshot", "wgpu"]` 撤除(egui_kittest 没有 default feature,`Harness` /
AccessKit / `run_steps` 不受影响)。
**不许用来变绿的路**:恢复快照基线、`check-package -AllowDirty`、把断言改成永真。
---
## 0.3.2 Wave E — 告警 toast(BD-UI-50)
**BD-UI-50(告警只有一条产生口,toast 只有一层载体)**:告警的产生归 `UiShared::note_gateway_status`
(状态轮询那一拍),toast 的呈现归 `src/toast.rs`(载体 `egui-notify 0.21.0`)。两件事都不许散:
产生口散到某张窗的渲染里 = 那张窗没开就没有告警;载体散成自绘 overlay = 与 dock / 表头覆盖层抢 z 序。
**载体口径(0.21.0 源码实核,交接报告 §6 第 2 条到这里才关掉)**
- `Toasts::show(ctx)` 把卡片画在 `LayerId::new(Order::Foreground, Id::new("toasts"))` 这个**独立图层**
(`egui-notify-0.21.0/src/lib.rs:211`)→ 不被我们的表头带 / dock 描边压住(方案 C13 的答案)。
判据能钉到它,是因为根 viewport 的**所有图层**都并进 `FullOutput.shapes`(§17 口径第 ① 条)。
- **toast 是 painter 画出来的,不是控件**:没有 `Response`、没有 AccessKit 节点,`Toast` 的
`level / caption / width / height / duration` 全是 `pub(crate)`(`toast.rs:62-74`),
`Toasts.toasts` 也是私有。所以判据只能走**本帧绘制指令**(正文与「▎」的文字形状),不能走语义树。
- **唯一的交互是右上角「❌」**(`lib.rs:387-392` 只对那颗叉做命中测试)。
→ Python 那条「点击卡片跳该合约 K 线」(`alert_toast.py` 的 `mousePressEvent`)**在这个 crate 里做不到**:
要做得复刻它的锚点与宽度公式(`toast.width` 每帧按 galley 重算,`lib.rs:323-328`)再自建命中层,
而那份公式我们没有可读的公开面。**本轮不做,记成欠账**(见本节末)。
- crate 没有「同屏上限」开关 → `SCREEN_CAP = 3` 由我们兜(第 4 条进来前先 `dismiss_oldest_toast`)。
- crate 没有 Python 的「左边框 4px 级别色」→ 级别可辨走 `ToastLevel::Custom("▎", Python 色)`,
**不假装像素一致**。
**位置与节律口径(照 Python `alert_toast.py`)**
| 位置 | `Anchor::BottomRight` + `reverse(true)` = 新的在最底、向上堆叠 | `_reposition` 的 `reversed()` |
| 边距 | 20 / 20 | `_margin_right = 20` / `_margin_bottom = 60`(60 是避任务栏,我们画在窗内容区里,不隔) |
| 卡片间距 | 10 | `_gap = 10` |
| 同屏上限 | 3(超限关最旧) | `_max_toasts = 3` |
| 自动关 | 8 秒 | `show_toast(duration=8000)` |
| 级别色 | `#1890FF / #FAAD14 / #FF4D4F` | `AlertToast._LEVEL_COLORS`(45-49) |
级别色只住在 `tokens::alert`(U2 单一色源钉:`src/` 里 `Color32::from_rgb(` 只许出现在 `tokens.rs`)。
**告警产生口(与 Python `AlertEngine` 的对照)**
- 唯一入口 = `note_gateway_status`:`{gw}.status` 读数折成登录态之后,`is_alarm()`
(登录失败 / 掉线)或 `QueryStarved`(查询断供)就灌一条 toast,文案用**状态细节原文**
(「资金回报已 31s 未更新」比「查询断供」更可行动)。
- 去重键 = `网关 + 登录态`:同一状态只弹一次,状态换档才算新告警。不去重的后果是每轮轮询一条,
8 秒自动关与同屏上限 3 都没有意义。
- 原来这条挂在账户面板渲染里(`render_accounts_window` 的状态细节循环)—— **已撤**,
账户窗没开也必须知道掉线了。E6 的机械扫描钉就是挡这个回潮的。
**判据(e1–e6,全部先红后绿)**
| e1 | 没有告警的那一帧 vs 灌一条告警的那一帧:右下角必须多出正文文字形状,「▎」必须取 ERROR 色 | 旧代码没有 `toast` 模块,编译面红 |
| e2 | 三级各一条 → 三条「▎」的实际取色 == Python 那三支,且正好三条 | 缺省 `ToastLevel::Error` 的 crate 色是 `#C85A5A`,不是 `#FF4D4F` |
| e3 | 三条正文按 y 升序 = 最早 / 中间 / 最新 → 最新的 y 最大(在最底) | `reverse(false)` 时顺序正好相反 |
| e4 | 灌 5 条 → 屏上 ≤ `SCREEN_CAP` | 不关最旧时 5 条全在 |
| e5 | 源码扫描:`Duration::from_secs(8)` / `Anchor::BottomRight` + `.reverse(true)` / `SCREEN_CAP: usize = 3` + `dismiss_oldest_toast` / 每帧 `drain_and_show` | 开关不显式写就红 |
| e6 | 状态层:轮询一拍出告警 / 同状态不重复 / 转好不弹 / 换档再弹 + 机械扫描「`push_alert(` 在 src/ 只许一处且在 state.rs」 | 旧 `note_gateway_status` 不产生告警 → 第一条就红(0 条) |
**连带口径(本轮实撞,写下来免得后人重踩)**
- **toast 在屏上时 egui-notify 每帧 `request_repaint`**(`lib.rs:434`,退场计时与出现动画)→
`Harness::run()` 的「等 UI 自己静下来」(max_steps = 4)直接炸。21 处改成 `harness.run_steps(4)`
定帧数。教训:**只要界面上有活计时器,就不许用 `run()`**。
- `egui_notify::Toasts` **没有 `Debug`**,而 `UiShared` 是 `#[derive(Debug)]` → 直接放
`Mutex<Option<Toasts>>` 编译不过。`toast::ToastsCell` 就是为这一条存在的(自己决定 Debug 印什么)。
- 常驻队列必须住在 `UiShared`:每帧新建 = 8 秒自动关与同屏上限都没有意义(与 `DockState` /
`Chart` 同一条纪律,交接报告 §5.3)。
**Wave E 没做的一条(欠账,不是已完成)**
「点击卡片跳该合约 K 线」做不到,原因在库不在我们(`Toast` 无公开交互面,见上)。
今天还有一层:**我们唯一的告警源是账户状态,它本来就没有「该合约」可跳** —— Python 那条能跳,
是因为它的告警来自按合约的 `AlertEngine` 规则;ek-ui 没有按合约的告警规则(数据侧缺口,不是 UI 缺口)。
出路两条:① 等告警带上 symbol 时自建命中层(要复刻 crate 的宽度公式,脆);② 换成自绘 toast
(~100 行,像素与交互全归我们,但推翻「toast 归 egui-notify」这条选型)。**本轮两条都没选**,
按 Wave E 的验收判据(三级色 + 8 秒 + 右下角向上堆叠 + 上限 3)收口。
## 0.3.3 Wave F — 列差集收口(BD-UI-51)
**为什么有这一轮**:六波做完之后,「跟 Python 长一样」这件事还剩一层没核 —— **列差集**。
逐列把我们的表头/表体和 Python 的四个 Monitor 摆在一起,查「我们多一列 / 少一列 / 列序不同 /
同一列取值不同」。核的过程抓到两个真 bug(不是差集,是错),这是本轮最大的收获。
**两个真 bug(判据当场红出来的)**
| 成交表少一格 | 表头 12 列,表体只发 11 个格 → 整行左移一列(`代码` 位印 `名称`),`RIGHT_COLS` 三列也落在错列 | 老判据只查「列名在场」(s21)与列宽几何(t2/t3),没查「表体第 i 格 == 表头第 i 列」 |
| 持仓成本/均价互换 | ek-data `avg_price` = 成本价、`price` = 持仓均价;Python 成本 ← `avg_price`、均价 ← `price`;我们写反 | s21 只查「成本/均价两列都在」,不看值 |
**列差集三条 + 第四条(本轮新查出来的)**
1. **成交表列序**:照 Python `TradeMonitor.headers`(`widget.py:1270-1283`)—— `代码` 首列、
`成交号` 第 10 列、`成交额` 改名 `成交金额`。**改**。
2. **行情表「交易所」列**:Python `TickMonitor` 有(`widget.py:709-725`)。**不补** ——
行情带按所分二级 tab,同一视图里每行「交易所」恒等于 tab 名,加列是纯噪还占宽度。
3. **委托表「操作」列**:Python `OrderMonitor` 没有(`widget.py:1341-1358`)。**保留** ——
撤单是危险操作,明钮比「双击 + tooltip」可审计(Python 的两条我们也都有)。
4. **枚举值显示语言**(新查出,不在原计划):我们印 wire 串(`LONG / OPEN / PARTTRADED / SHFE`),
Python 印中文(`多 / 开 / 部分成交 / 上期所`);ek-data 有 `chinese_alias()` 可直取。
**本轮不改**:牵动下单面板下拉、CSV 导出、排序键与既有判据(s11 按 `PARTTRADED` 查树、
s18 按 `SHFE.rb2605` 点合约),值得单独一版。已写进交接报告待办。
**判据(f1–f4,全部先红后绿)**
| f1 | 成交行 12 个夹具值各一处 + 表体按 x 升序 == 期望序列 + 表头 12 列按 x 升序 == 期望序列 | `F1SYM` 查不到(那格没建构);补格后列序仍错也红 |
| f2 | 持仓行:成本表头 < `98.25`(avg_price) < 均价表头 < `101.5`(price) < 最新价表头 < `77.125` | 成本格印的是 `price` → 第一条就红 |
| f3 | 源码扫描:`TRADE_HEADERS` 字面量逐个按 Python 序出现,且是单源 12 列常量 | 成交号在首列 → 红 |
| f4 | 源码扫描:`render.rs` 里不许有带精度/符号/对齐的裸 `format!` 说明符(排序键两支放行);`format.rs` 必须齐六支 | 持仓/资金/涨跌幅/最小变动共 10 处裸格式 → 红 |
**Wave D 欠账收口的三条(同一版本 0.3.3,BD-UI-52)**
| w5 | 右键首格 → 菜单「调整列宽 / 保存数据」两节点在场;**再点「调整列宽」必须真的翻 `table_resize_on`**;**再点「保存数据」必须落 `target/ek-ui-trade_grid.csv`,且表头逐字 == 屏幕表头、每行 12 字段** | 原来整条 `#[ignore]`(体表格不吃二级点击)→ 0 节点 |
| w8 | 委托表点「价格」表头 → O-2(3.5) 到 O-1(101) 之上;再点翻降序 | `table_sort("order_grid") == None`,行序不动 |
| w9 | 持仓表点「数量」表头 → y 序 1 < 3 < 5 | `table_sort("pos_grid") == None` |
| w10 | 行情带点「最新价」表头 → 5 < 98 < 12345(缺省序是按 symbol,所以必须**变**) | `table_sort("tick_grid") == None` |
**这一段自查里揪出来的假判据(留着给后人看:判据自己也会骗人)**
- `f3` 的第一版在**整份 render.rs 里做子序列查找**(「这些列名按这个先后出现」)——
实测把 `TRADE_HEADERS` 前两列对调它**照样绿**,因为它命中的是更靠前的 `ORDER_HEADERS`。
改成「先切出 `TRADE_HEADERS` 字面量块,再逐个取字符串字面量比序」后,错序当场红。
**教训:源码扫描必须先把判据对象切出来,再比;全文件 find 出来的绿是假绿。**
- `f4` 的第一版只认 `format!("{:...`(无名参数)两种子串,`format!("{price:.2}")` 这种
**命名参数**会整条漏过去。改成逐条 `format!` 抠 `{...}` 说明符再判(带 `.` / `+` / `>` 即违规),
只放行排序键那两支零填充。
- `w5` 的第一版只数「菜单项节点存在」—— 挡不住「菜单项挂了个空壳」。补了「点下去状态真的变」
与「CSV 真的落地且列数与屏幕同源」两条。
**连带口径(写下来免得后人重踩)**
- **表头 `interact` 一直是通的,缺的只是「有人接 `clicked`」**。委托表/持仓表/行情带 0.3.1
起就能点出 `clicked`,只是调用点丢掉了返回值 —— 补排序是「接线」不是「加交互」。
- **被横向裁剪的格仍在语义树里,矩形也还在**(表体节点不受 `clip_rect` 影响)。
于是「靠 x 窗挑这一行的格」会把**邻表溢出列**也挑进来(实测:1280 宽时委托表 17 列装不下,
它的「时间」表头矩形伸进成交表的 x 窗,同一行里出现两处「时间」)。
正解:① 判据夹具给宽窗(2600×900),两张表都不横向裁剪;② 或用「该表专属表头」定行 y。
- **列序改了,老判据的锚点会搬走**:W1/W3/T4/W5 原来都锚在「成交号」或 `T-1`(成交号列),
列序照 Python 之后它们落到横向裁剪之外 —— **裁剪外的坐标点不到那一格**
(节点还在树里,命中测试不吃)。四处锚点全部改到「代码/名称」列。
- **表头文字挂排序箭头后,精确标签匹配会失手**:点过一次的表头变成 `价格 ▲`,
再要抓它必须用 `_contains`(W8 第二次点击即此形)。
- **`context_menu` 挂真控件才收得到二级点击**(W5 的定性):体表格的格先吃过 hit-test,
体表层后补的 `interact` 与表不同层 → `secondary_clicked` 永远为假。
首格改成 `Label + Sense::click()` 之后菜单就绿了 —— 结论不是「kittest 不行」,是「挂错了层」。
**欠账(不是已完成)**
- 行情带/委托表/持仓表**排序入口有了,但列宽拖动仍只有合约表**(`resizable` 口径未变);
右键菜单里的「调整列宽」是全局开关(`table_resize_on`),不是逐表拖边界。
- 「保存数据」只在成交表现有(CSV 走 `trade_cells`,与屏幕同源);委托/持仓/行情带没有导出。
- **排序键的列号与表头列号只有三处交叉钉**(w8 钉委托表第 7 列价格、w9 钉持仓表第 5 列数量、
w10 钉行情带第 3 列最新价)。若日后有人**同时**改 `ORDER_HEADERS`/`POS_HEADERS` 与对应
`*_sort_key` 的列号,其它列的点表头会静默排错列 —— 目前没有一条钉能全列覆盖。
真要补,形状是「点第 i 个表头 → 行序 == 按该列显示串排序」(12 列 × 4 张表 = 48 次点击,
成本明显,故未做)。**这是本轮留下的已知缺口,不是已覆盖。**
- 委托表第 17 列「操作」没有可排的数据(排序键给空串):点它只挂箭头不换序 —— 无害但会让人
以为坏了。要治就得把「操作」列从表头点击目标里摘出来(现在 `pinned_table_impl` 对每列一视同仁)。
- **右键菜单只挂在成交表首格(代码)那颗真控件上** —— 横向滚到右边再右键别的格不会出菜单。
Python 是整表右键。要治得给每行一颗覆盖全行宽的真控件(或等 egui 让体表格的格报二级点击),
本轮未做。
- 「保存数据」的落点仍是 **`cwd/target/ek-ui-<表>.csv`**(0.3.1 起如此):生产里 cwd 由快捷方式
决定,不确定 —— 应收成用户文档目录或让用户选路径。另:CSV **不做字段转义**,今天是安全的
(成交表 12 列全是代码/枚举/数字/时间),但**加任何自由文本列之前必须先转义**,否则一个逗号就错列。