Skip to main content

ek_ui/
mount.rs

1//! mount —— 模块挂载表(0.9.34「插件界面 = 一模块一菜单」波)。
2//!
3//! 规则(用户裁定,2026-11-01):
4//! - **一个模块([`ek_core::Plugin::kind`])一个菜单项、一扇窗**;菜单项数**只随模块数变**,
5//!   不随实例数变(`ctp_278153` / `ctp_220380` 共用一个「交易网关」项)。
6//! - 窗内怎么分(表格 / tab / 分栏)由模块自己决定 —— CTP 用一张账户表,dm/oms/tq 用各自
7//!   的 `ui_face`。框架只负责"取到实例、把宿主交进去"。
8//! - 关窗**不销毁**实例(= Python `self.widgets` 字典),再点直接复用编辑中的内容。
9//! - 装配名单驱动:没装的不出现;`always = true` 的模块(交易网关 / 宿主自有窗)恒在。
10//!
11//! 与已退役的 `faces.rs`(0.9.10 连根铲)的关系:那条线是"一实例一面",本模块换成
12//! "一模块一窗",并把派发收成 [`Body`] 枚举 + `match`(不引 trait、不建新 crate)。
13
14use std::sync::Arc;
15
16use ek_core::cfg_face::CfgHost;
17use ek_core::context::PluginContext;
18use ek_core::main_engine::MainEngine;
19use ek_core::section::SectionMap;
20
21use crate::state::UiShared;
22
23/// 窗内形状(A8)。本期只实现 [`Shape::Single`];`PerInstance` 是留给
24/// "确实需要按实例拆二级菜单"的模块的逃生门(字段就位、行为不实现)。
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum Shape {
27    /// 一模块一窗,窗内自己分(默认)。
28    #[default]
29    Single,
30    /// 框架按实例拆二级菜单 + 多窗(本期不实现,仅占位)。
31    PerInstance,
32}
33
34/// 一扇模块窗的体。新增一个插件面 = 加一个变体 + 一个 `match` 分支
35/// (`scripts/check_plugin_faces.ps1` 会比对"有面必有体")。
36pub enum Body {
37    /// ek-dm 自带面。
38    Dm(ek_dm::ui_face::DmUiState),
39    /// ek-oms 自带面。
40    Oms(ek_oms::ui_face::OmsUiState),
41    /// ek-tq 自带面。
42    Tq(ek_tq::ui_face::TqUiState),
43    /// 交易网关(宿主模块:账户表;S4)。
44    Gateway(crate::gateway::TableState),
45    /// 宿主自有窗(运维 / 查询合约 / K线;S5)。
46    Host(crate::hostwin::State),
47    /// 策略交易(ek-strategy 插件面;0.9.47)。
48    // 装箱:窗态(表单 + 详情草稿)比别的分支大一圈,装进 `Box` 免得整个 `Body` 被撑肥。
49    Strategy(Box<crate::strategy_win::StrategyWinState>),
50}
51
52/// 一扇模块窗。
53pub struct ModuleWin {
54    /// 窗口键(`egui` viewport id / 嵌入窗 id 都吃它,保证多窗互不踩)。
55    pub id: String,
56    /// 模块名(= [`ek_core::Plugin::kind`])。
57    pub kind: String,
58    /// 菜单文字 + 窗标题。
59    pub title: String,
60    /// emoji(菜单前缀)。
61    pub icon: &'static str,
62    /// 菜单内排序。
63    pub order: i32,
64    /// 窗内形状。
65    pub shape: Shape,
66    /// 该模块当前实例名(装配后由 [`Registry::sync`] 维护)。
67    pub instances: Vec<String>,
68    /// `true` = 未装配也出现(交易网关 / 宿主自有窗)。
69    pub always: bool,
70    /// 打开即最大化(BD-UI-34:K线窗用户裁定)。
71    pub maximized: bool,
72    /// 开合(关窗不销毁实例)。
73    pub open: bool,
74    /// 默认内尺寸。
75    pub size: [f32; 2],
76    /// 最小内尺寸。
77    pub min_size: [f32; 2],
78    /// 窗体。
79    pub body: Body,
80}
81
82impl std::fmt::Debug for ModuleWin {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("ModuleWin")
85            .field("id", &self.id)
86            .field("kind", &self.kind)
87            .field("instances", &self.instances)
88            .field("open", &self.open)
89            .finish()
90    }
91}
92
93impl ModuleWin {
94    /// 本窗要渲染的实例名(`Single` 取第一个;无实例 = 空串)。
95    #[must_use]
96    fn primary_instance(&self) -> &str {
97        self.instances.first().map(String::as_str).unwrap_or("")
98    }
99}
100
101/// 挂载表。
102pub struct Registry {
103    /// 模块窗(按 `order` 排序后使用)。
104    pub list: Vec<ModuleWin>,
105}
106
107impl Default for Registry {
108    /// 空表也是"合法表":`always` 模块在 [`Registry::with_always`] 里补齐,
109    /// `Default` 只给 `#[derive(Default)] UiShared` 用(测试无引擎时也不炸)。
110    fn default() -> Self {
111        Self { list: Vec::new() }
112    }
113}
114
115impl std::fmt::Debug for Registry {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_list()
118            .entries(self.list.iter().map(|m| (&m.id, &m.instances)))
119            .finish()
120    }
121}
122
123// ================ 模块描述(一模块一行;新增模块只改这张表)================
124
125/// 模块描述(静态元数据)。
126pub struct ModDesc {
127    pub kind: &'static str,
128    pub title: &'static str,
129    pub icon: &'static str,
130    pub order: i32,
131    pub size: [f32; 2],
132    pub min_size: [f32; 2],
133    pub always: bool,
134    /// 打开即最大化(BD-UI-34:K线窗用户裁定)。
135    pub maximized: bool,
136}
137
138/// 已知模块表。**顺序无关**(渲染按 `order`)。
139pub const MODULES: &[ModDesc] = &[
140    ModDesc {
141        kind: "ctp",
142        title: "交易网关",
143        icon: "🔗",
144        order: 10,
145        size: [1100.0, 560.0],
146        min_size: [720.0, 360.0],
147        always: true,
148        maximized: false,
149    },
150    ModDesc {
151        kind: "dm",
152        title: "数据管理",
153        icon: "📊",
154        order: 20,
155        size: [560.0, 420.0],
156        min_size: [380.0, 260.0],
157        // 0.9.37(用户裁定):**有界面的插件常驻菜单**,不等装配 —— 窗口本体在插件
158        // 缺席时显示「未装配」提示,菜单项本身不许消失(消失 = 用户找不到入口)。
159        always: true,
160        maximized: false,
161    },
162    ModDesc {
163        kind: "oms",
164        title: "交易中枢",
165        icon: "⚙",
166        order: 30,
167        size: [560.0, 460.0],
168        min_size: [380.0, 280.0],
169        always: true,
170        maximized: false,
171    },
172    ModDesc {
173        kind: "tq",
174        title: "天勤桥",
175        icon: "🌉",
176        order: 40,
177        size: [520.0, 400.0],
178        min_size: [360.0, 260.0],
179        always: true,
180        maximized: false,
181    },
182    ModDesc {
183        kind: "ops",
184        title: "运维",
185        icon: "🛠",
186        order: 50,
187        size: [680.0, 420.0],
188        min_size: [420.0, 260.0],
189        always: true,
190        maximized: false,
191    },
192    ModDesc {
193        kind: "contracts",
194        title: "查询合约",
195        icon: "🔍",
196        order: 60,
197        size: [620.0, 460.0],
198        min_size: [420.0, 260.0],
199        always: true,
200        maximized: false,
201    },
202    ModDesc {
203        kind: "kline",
204        title: "K线",
205        icon: "📈",
206        order: 70,
207        size: [560.0, 360.0],
208        min_size: [360.0, 220.0],
209        always: true,
210        maximized: true,
211    },
212];
213
214/// 扫引擎里在册插件 → 按 `kind` 归组(**不碰挂载表锁**;见 `sync_groups` 注释)。
215///
216/// 0.9.47:同时把插件**自报的窗描述**带出来(`Plugin::window()`)。这是"菜单自动
217/// 生成"的数据来源 —— 静态 `MODULES` 表里没有的模块靠它建窗。**必须在锁外取**,
218/// 理由见 [`Registry::sync_groups`]。
219#[must_use]
220pub fn scan_groups(shared: &UiShared) -> std::collections::BTreeMap<String, GroupInfo> {
221    let mut groups: std::collections::BTreeMap<String, GroupInfo> =
222        std::collections::BTreeMap::new();
223    if let Some(engine) = shared.engine_arc() {
224        for name in engine.plugin_names() {
225            if let Some(p) = engine.plugin_arc(&name) {
226                let kind = p.kind().to_string();
227                let e = groups.entry(kind).or_default();
228                e.instances.push(name);
229                // 同一 kind 的多实例共用一份描述(取第一个非空)。
230                if e.desc.is_none() {
231                    e.desc = p.window();
232                }
233            }
234        }
235    }
236    groups
237}
238
239/// 一个模块的扫描结果(锁外采集,[`Registry::sync_groups`] 只消费它)。
240#[derive(Debug, Clone, Default)]
241pub struct GroupInfo {
242    /// 该模块当前的实例名(装配序)。
243    pub instances: Vec<String>,
244    /// 插件自报的窗描述(静态表里没有的模块靠它建窗)。
245    pub desc: Option<ek_core::WindowDesc>,
246}
247
248/// 取模块描述:**静态表优先,插件自报兜底**。
249///
250/// 静态表优先的理由:老模块的描述经过复核(尺寸/顺序/`always` 都调过),
251/// 插件自报只用于"新插件,表里还没有"。
252fn desc_of(kind: &str, group: Option<&GroupInfo>) -> Option<ModDesc> {
253    if let Some(d) = MODULES.iter().find(|d| d.kind == kind) {
254        return Some(ModDesc {
255            kind: d.kind,
256            title: d.title,
257            icon: d.icon,
258            order: d.order,
259            size: d.size,
260            min_size: d.min_size,
261            always: d.always,
262            maximized: d.maximized,
263        });
264    }
265    let d = group?.desc?;
266    // 自报描述必须自称与本 kind 同名(防插件报错 id 后挂到别人脸上)。
267    if d.id != kind {
268        return None;
269    }
270    // 尺寸不合法 → 用一套保守默认,不让笔误把窗开成 0×0。
271    let (size, min_size) = if d.sizes_ok() {
272        (d.size, d.min_size)
273    } else {
274        ([640.0, 420.0], [360.0, 240.0])
275    };
276    Some(ModDesc {
277        kind: d.id,
278        title: d.title,
279        icon: d.icon,
280        order: d.order,
281        size,
282        min_size,
283        always: d.always,
284        maximized: d.maximized,
285    })
286}
287
288/// 由模块名建窗体。**本函数就是"新插件面"的唯一登记点**:
289/// 插件有 `ui_face` 却没在这里登记 = `scripts/check_plugin_faces.ps1` 红。
290fn body_of(kind: &str) -> Option<Body> {
291    match kind {
292        "dm" => Some(Body::Dm(ek_dm::ui_face::DmUiState::new())),
293        "oms" => Some(Body::Oms(ek_oms::ui_face::OmsUiState::new())),
294        "tq" => Some(Body::Tq(ek_tq::ui_face::TqUiState::new())),
295        "ctp" => Some(Body::Gateway(crate::gateway::TableState::new())),
296        "ops" => Some(Body::Host(crate::hostwin::State::new(
297            crate::hostwin::HostKind::Ops,
298        ))),
299        "contracts" => Some(Body::Host(crate::hostwin::State::new(
300            crate::hostwin::HostKind::Contracts,
301        ))),
302        // 菜单里那一项「K线」只是入口(点了按当前合约开一扇真窗);真窗体在
303        // `sync_hosts` 里按 `kline_symbols()` 逐合约建。
304        "kline" => Some(Body::Host(crate::hostwin::State::new(
305            crate::hostwin::HostKind::Kline {
306                symbol: String::new(),
307            },
308        ))),
309        // 0.9.47:策略交易 —— 模块窗由 ek-strategy 插件**自报**(`Plugin::window()`),
310        // 本表里没有它的字面项;这里只是"哪个渲染器"的登记点(G2 靠它)。
311        "strategy" => Some(Body::Strategy(Box::new(
312            crate::strategy_win::StrategyWinState::new(),
313        ))),
314        _ => None,
315    }
316}
317
318impl Registry {
319    /// 只要 [`MODULES`] 里标了 `always` 的模块(交易网关 / 宿主自有窗)。
320    ///
321    /// `UiShared::new()` 用它 —— 保证"没引擎、没装配"时这几扇窗也在菜单里
322    /// (先建账户档案再连接,是 CTP 的正常用法)。
323    #[must_use]
324    pub fn with_always() -> Self {
325        let mut reg = Self::default();
326        reg.ensure_always();
327        reg
328    }
329
330    fn ensure_always(&mut self) {
331        for d in MODULES.iter().filter(|d| d.always) {
332            if self.list.iter().any(|w| w.kind == d.kind) {
333                continue;
334            }
335            if let Some(body) = body_of(d.kind) {
336                self.list.push(ModuleWin {
337                    id: d.kind.to_string(),
338                    kind: d.kind.to_string(),
339                    title: d.title.to_string(),
340                    icon: d.icon,
341                    order: d.order,
342                    shape: Shape::Single,
343                    instances: Vec::new(),
344                    always: true,
345                    maximized: d.maximized,
346                    open: false,
347                    size: d.size,
348                    min_size: d.min_size,
349                    body,
350                });
351            }
352        }
353        self.list.sort_by_key(|w| (w.order, w.id.clone()));
354    }
355
356    /// 模块窗开合读。
357    #[must_use]
358    pub fn is_open(&self, kind: &str) -> bool {
359        self.list
360            .iter()
361            .find(|w| w.kind == kind)
362            .is_some_and(|w| w.open)
363    }
364
365    /// 模块窗开合写(`always` 模块不在册时先补建 —— 测试/启动器阶段无引擎也通)。
366    pub fn set_open(&mut self, kind: &str, open: bool) {
367        if self.list.iter().all(|w| w.kind != kind) {
368            self.ensure_always();
369        }
370        if let Some(w) = self.list.iter_mut().find(|w| w.kind == kind) {
371            w.open = open;
372        }
373    }
374
375    /// 按引擎当前在册插件建挂载表(开机装配完成后调一次)。
376    #[must_use]
377    pub fn mount(shared: &UiShared) -> Self {
378        let mut reg = Self::with_always();
379        reg.sync(shared);
380        reg
381    }
382
383    /// 同步实例列表:插件增删后调(`svctl.start/remove` 回执处)。
384    ///
385    /// 语义:
386    /// - 已有模块窗**保留**(开合、窗内编辑态都不丢),只更新 `instances`;
387    /// - 新模块(首次出现)建窗并挂上;
388    /// - 模块的实例全部摘掉 → 非 `always` 的窗下架。
389    pub fn sync(&mut self, shared: &UiShared) {
390        let groups = scan_groups(shared);
391        self.sync_groups(&groups);
392    }
393
394    /// 只吃「按 kind 归好的实例表」的那一半(0.9.40)。
395    ///
396    /// 拆出来是为了让调用方**在锁外**做引擎扫描:旧写法持着挂载表锁去扫引擎,
397    /// 而引擎在插件起停时会短暂持锁(装配 2~4 秒)—— 主界面点菜单就跟着卡死。
398    ///
399    /// 0.9.47:入参从 `Vec<实例名>` 换成 [`GroupInfo`](多带一份插件自报的窗描述),
400    /// **本方法仍然不碰引擎** —— 描述是 [`scan_groups`] 在锁外顺手取的。
401    pub fn sync_groups(&mut self, groups: &std::collections::BTreeMap<String, GroupInfo>) {
402        // 1) 更新/新增
403        for (kind, info) in groups {
404            if let Some(w) = self.list.iter_mut().find(|w| &w.kind == kind) {
405                w.instances = info.instances.clone();
406                continue;
407            }
408            let Some(d) = desc_of(kind, Some(info)) else {
409                continue;
410            };
411            let Some(body) = body_of(kind) else { continue };
412            self.list.push(ModuleWin {
413                id: d.kind.to_string(),
414                kind: d.kind.to_string(),
415                title: d.title.to_string(),
416                icon: d.icon,
417                order: d.order,
418                shape: Shape::Single,
419                instances: info.instances.clone(),
420                always: d.always,
421                maximized: d.maximized,
422                open: false,
423                size: d.size,
424                min_size: d.min_size,
425                body,
426            });
427        }
428        // 2) always 模块恒在(未装配也建窗)
429        self.ensure_always();
430        // 2.5) 实例全摘的模块要把实例表清零(否则摘光了还挂着旧名单)
431        for w in &mut self.list {
432            if !groups.contains_key(&w.kind) {
433                w.instances.clear();
434            }
435        }
436        // 3) 非 always 且没实例 → 下架
437        self.list
438            .retain(|w| w.always || groups.contains_key(&w.kind));
439        // 4) 稳定排序(菜单顺序只由 order 决定,与装配先后无关)
440        self.list.sort_by_key(|w| (w.order, w.id.clone()));
441    }
442
443    /// 菜单区渲染:**一项 = 一个模块**(点谁开谁;再点置真,幂等)。
444    ///
445    /// 文字 = 模块标题本身(不带 emoji 前缀):菜单的可检索文本与模块名一一对应,
446    /// 图标只进 tooltip —— 判据(含 kittest 按 label 取件)只认标题,不认装饰。
447    pub fn menu(&mut self, ui: &mut egui::Ui, shared: &UiShared) {
448        for w in &mut self.list {
449            // 动态窗(`kline:{合约}`)不进菜单:菜单只有一项「K线」,
450            // 真窗由 `sync_hosts` 按 `kline_symbols()` 建。
451            if w.id.starts_with("kline:") {
452                continue;
453            }
454            if !ui
455                .button(w.title.as_str())
456                .on_hover_text(format!(
457                    "{} {}:{} 个实例(点开看它自己的窗)",
458                    w.icon,
459                    w.title,
460                    w.instances.len()
461                ))
462                .clicked()
463            {
464                continue;
465            }
466            // 宿主自有窗:开合事实源仍是 UiShared 老旗子(挂载表只是视图)。
467            match w.id.as_str() {
468                "ops" => shared.set_ops_window_open(true),
469                "contracts" => shared.set_contracts_window_open(true),
470                _ => w.open = true,
471            }
472        }
473    }
474
475    /// 窗口区渲染:开着的逐个开(都在独立 OS 窗;不支持多窗的后端走主窗内嵌)。
476    ///
477    /// ⚠ 重入纪律:本方法持着挂载表锁渲染整扇窗 —— **窗体内不许再调**
478    /// [`UiShared::modules`](`Mutex` 非重入,会自锁)。窗体内的开合要么走
479    /// `UiShared` 的老旗子(`set_ops_window_open` 等,纯原子位),要么等下一拍由
480    /// 调用方处理。
481    pub fn windows(&mut self, ctx: &egui::Context, shared: &UiShared) {
482        self.sync_hosts(shared);
483        shared.begin_window_frame(); // 0.9.45:本帧可见集重置(见 `window_was_visible`)
484        for w in &mut self.list {
485            // 菜单入口「K线」不是真窗:点了按当前合约开一扇(`sync_hosts` 下一拍建窗)。
486            if w.id == "kline" {
487                if w.open {
488                    w.open = false;
489                    let sym = shared.order_form().symbol.trim().to_string();
490                    if sym.is_empty() {
491                        shared.log_line("[kline] 先在行情表选一个合约再点「K线」".to_string());
492                    } else {
493                        shared.open_kline(sym);
494                    }
495                }
496                continue;
497            }
498            if !w.open {
499                continue;
500            }
501            let closed = show_module_window(ctx, w, shared);
502            shared.note_window_visible(&w.id); // 渲染过 = 本帧在屏(有头取证口径)
503            if closed {
504                w.open = false;
505                match w.id.as_str() {
506                    "ops" => shared.set_ops_window_open(false),
507                    "contracts" => shared.set_contracts_window_open(false),
508                    id if id.starts_with("kline:") => shared.close_kline(&id[6..]),
509                    _ => {}
510                }
511            }
512        }
513    }
514
515    /// 宿主自有窗:开合从 UiShared 读回;K线按 `kline_symbols()` 逐合约建/收。
516    fn sync_hosts(&mut self, shared: &UiShared) {
517        for w in &mut self.list {
518            match w.id.as_str() {
519                "ops" => w.open = shared.ops_window_open(),
520                "contracts" => w.open = shared.contracts_window_open(),
521                _ => {}
522            }
523        }
524        let syms = shared.kline_symbols();
525        self.list.retain(|w| {
526            !w.id.starts_with("kline:") || syms.iter().any(|s| w.id == format!("kline:{s}"))
527        });
528        for s in syms {
529            let id = format!("kline:{s}");
530            if self.list.iter().any(|w| w.id == id) {
531                continue;
532            }
533            self.list.push(ModuleWin {
534                id,
535                kind: "kline".to_string(),
536                title: format!("K线 — {s}"),
537                icon: "📈",
538                order: 71,
539                shape: Shape::Single,
540                instances: Vec::new(),
541                always: true,
542                maximized: true,
543                // 打开即显示(K线出现=窗就在);BD-UI-34 的"打开即最大化"改由
544                // 窗口尺寸给足 + 用户自己最大化(统一窗框不按 kind 特判 maximized)。
545                open: true,
546                size: [900.0, 620.0],
547                min_size: [360.0, 220.0],
548                body: Body::Host(crate::hostwin::State::new(
549                    crate::hostwin::HostKind::Kline { symbol: s.clone() },
550                )),
551            });
552        }
553        self.list.sort_by_key(|w| (w.order, w.id.clone()));
554    }
555}
556
557// ================ 宿主桥(插件窗不碰文件/密库/线程)================
558
559/// [`CfgHost`] 的 ek-ui 实现:写档走 `cfg.save` 总线,`cfg` 缺席 = 直写托底
560/// (与 `plugin.rs::cfg_bus_save` 同口径,不另造一条写路)。
561pub struct CfgBridge<'a> {
562    shared: &'a UiShared,
563    ctx: Option<PluginContext>,
564    /// 显式存储根(判据/装配者注入;`None` = 走 `ui_cfg_store()` 缺省根)。
565    store: Option<ek_cfg::Store>,
566}
567
568impl<'a> CfgBridge<'a> {
569    /// 用当前共享面 + 引擎里的 ui 插件句柄建桥。
570    #[must_use]
571    pub fn new(shared: &'a UiShared) -> Self {
572        let ctx = shared.engine_arc().and_then(|engine| {
573            engine
574                .get_typed::<crate::plugin::UiPlugin>(crate::plugin::UI_NAME)
575                .and_then(|p| p.ctx_handle())
576        });
577        Self {
578            shared,
579            ctx,
580            store: None,
581        }
582    }
583
584    /// 钉死存储根(判据专用:写/读都落这一次给的根,不碰缺省根)。
585    #[must_use]
586    pub fn with_store(shared: &'a UiShared, store: ek_cfg::Store) -> Self {
587        Self {
588            shared,
589            ctx: None,
590            store: Some(store),
591        }
592    }
593
594    fn store(&self) -> ek_cfg::Store {
595        match &self.store {
596            Some(s) => s.clone(),
597            None => crate::plugin::ui_cfg_store(),
598        }
599    }
600}
601
602impl CfgHost for CfgBridge<'_> {
603    fn cfg_save(&self, ns: &str, map: &SectionMap) -> Result<(), String> {
604        if let Some(ctx) = &self.ctx {
605            match ctx.ask_service::<ek_cfg::wire::SaveReq, ek_cfg::wire::SaveRes>(
606                "cfg.save",
607                ek_cfg::wire::SaveReq {
608                    ns: ns.to_string(),
609                    map: map.clone(),
610                },
611                std::time::Duration::from_millis(1500),
612            ) {
613                Ok(r) if r.ok => return Ok(()),
614                Ok(r) => return Err(r.msg),
615                Err(ek_core::error::AskError::UnknownService { .. }) => {
616                    self.shared
617                        .log_line(format!("[cfg] cfg 缺席,{ns} 直写托底(软集中负形)"));
618                }
619                Err(e) => return Err(format!("{e:?}")),
620            }
621        }
622        self.store().save(ns, map).map_err(|e| e.to_string())
623    }
624
625    fn cfg_load(&self, ns: &str) -> Result<Option<SectionMap>, String> {
626        match self.store().load(ns) {
627            Ok(ek_cfg::LoadOutcome::Loaded { map, .. }) => Ok(Some(map)),
628            Ok(ek_cfg::LoadOutcome::Fresh) => Ok(None),
629            Err(e) => Err(e.to_string()),
630        }
631    }
632
633    fn log(&self, line: &str) {
634        self.shared.log_line(line.to_string());
635    }
636}
637
638// ================ 窗口开法 ================
639
640/// 独立 OS 窗(不支持多窗的后端回退成主窗内嵌窗)。
641///
642/// 与 `render::show_independent_window` 的差别:本函数**按 `id`** 做 viewport/窗键
643/// (旧函数按 title 做键,带参数的窗会互踩),且用一个闭包跑两路(嵌入回退与 OS 窗
644/// 共用同一个 `&mut Body`,不需要两份可变借用)。
645///
646/// 返回 `true` = 本拍该窗被关。
647fn show_module_window(ctx: &egui::Context, w: &mut ModuleWin, shared: &UiShared) -> bool {
648    let id = w.id.clone();
649    // 宿主自有窗的窗标题带「窗」后缀:菜单项 = 标题,窗标题 = 标题+窗 ——
650    // 两者是不同的可检索文本(kittest 按 label 取件时不会互撞)。
651    let title = match w.kind.as_str() {
652        "ops" | "contracts" => format!("{}窗", w.title),
653        _ => w.title.clone(),
654    };
655    let size = w.size;
656    let min = w.min_size;
657    let inst = w.primary_instance().to_string();
658    let mut open = w.open;
659    let mut closed = false;
660    let body = &mut w.body;
661
662    let mut builder = egui::ViewportBuilder::default()
663        .with_title(&title)
664        .with_inner_size(size)
665        .with_min_inner_size(min)
666        .with_minimize_button(true)
667        .with_maximize_button(true)
668        .with_close_button(true);
669    if w.maximized {
670        builder = builder.with_maximized(true);
671    }
672    ctx.show_viewport_immediate(
673        egui::ViewportId::from_hash_of(("module_win", &id)),
674        builder,
675        |ctx2, class| {
676            if class == egui::ViewportClass::Embedded {
677                egui::Window::new(&title)
678                    .id(egui::Id::new(("module_win", &id)))
679                    .open(&mut open)
680                    .default_size(size)
681                    .show(ctx2, |ui| {
682                        render_body(body, shared, &inst, ui);
683                    });
684                if !open {
685                    closed = true;
686                }
687                return;
688            }
689            if ctx2.input(|i| i.viewport().close_requested()) {
690                closed = true;
691                return;
692            }
693            egui::CentralPanel::default().show(ctx2, |ui| {
694                render_body(body, shared, &inst, ui);
695            });
696        },
697    );
698    closed
699}
700
701/// 窗体派发(**不用 trait,用 `match`**:编译期穷举,删一个分支 = 编译/门禁双红)。
702fn render_body(body: &mut Body, shared: &UiShared, inst: &str, ui: &mut egui::Ui) {
703    match body {
704        Body::Dm(st) => {
705            // 0.9.44(**未装配也要有配置面**,用户裁定;PLAN S2):
706            // 桥接反正引擎无关(`CfgBridge` 直读存储根),实例在不在只影响「运行态
707            // 读数块」—— 面自己会显形「未就绪」。老的两级早期 return(引擎未就绪/
708            // 未装配 = 整扇窗剩一行)就此退场。
709            let engine = shared.engine_arc();
710            let p = engine
711                .as_ref()
712                .and_then(|e| e.get_typed::<ek_dm::plugin::DmPlugin>(inst));
713            let host = CfgBridge::new(shared);
714            if ek_dm::ui_face::render(ui, p.as_deref(), &host, st) {
715                ui.ctx().request_repaint();
716            }
717        }
718        Body::Oms(st) => {
719            let engine = shared.engine_arc();
720            let p = engine
721                .as_ref()
722                .and_then(|e| e.get_typed::<ek_oms::plugin::OmsPlugin>(inst));
723            let host = CfgBridge::new(shared);
724            if ek_oms::ui_face::render(ui, p.as_deref(), &host, st) {
725                ui.ctx().request_repaint();
726            }
727        }
728        Body::Tq(st) => {
729            let engine = shared.engine_arc();
730            let p = engine
731                .as_ref()
732                .and_then(|e| e.get_typed::<ek_tq::plugin::TqPlugin>(inst));
733            let host = CfgBridge::new(shared);
734            if ek_tq::ui_face::render(ui, p.as_deref(), &host, st) {
735                ui.ctx().request_repaint();
736            }
737        }
738        Body::Gateway(st) => crate::gateway::render(ui, st, shared),
739        Body::Host(st) => crate::hostwin::render(ui, st, shared),
740        Body::Strategy(st) => {
741            if crate::strategy_win::render(ui, st.as_mut(), shared) {
742                ui.ctx().request_repaint();
743            }
744        }
745    }
746}
747
748/// 便利:引擎句柄(窗口体里取插件实例用)。
749#[must_use]
750pub fn engine_of(shared: &UiShared) -> Option<Arc<MainEngine>> {
751    shared.engine_arc()
752}