ek-ui 0.9.4

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
//! 运行时装配:工厂表(ui 工厂注册)、宿主观察线程(关窗→close_all)、
//! 进程主入口(eframe 主线程 + 宿主观察线程)。
//!
//! 线程形貌(S1b 双线程纪律):
//! - 主线程:eframe run_native(App 只读 [`UiShared`]);
//! - 观察线程:sleep 轮询 `UiShared::is_stop` → `engine.close_all`;
//! - 等价 headless 形貌见 ek-station bin(不带 ui 工厂)。

use std::sync::Arc;
use std::time::Duration;

use ek_core::main_engine::MainEngine;
use ek_core::plugin::Plugin;
use ek_dm::plugin::DmConfig;
use ek_station::{PlugKind, StationConfig, default_kinds};

use crate::plugin::{UI_NAME, UiPlugin};
use crate::state::UiShared;

/// ek-station 缺省工厂表(dm/oms/ctp;0.1.12 起 sim 已退场)+ `ui` 工厂 +
/// **rt-on 的 dm 工厂**(S4:UI 宿主观测面需要 `eBarUpdate.` 实时门;
/// ek-station headless 保持 rt-off 缺省不动 —— 两宿主的 dm 配置差异在各自
/// 工厂表,互不影响)。
///
/// `dm_rt` 是装配期参数(非配置文件;ek-ui 零配置口径,缺省 true);
/// 多次 build 同享一个 `UiShared`(ui 插件被 svctl 摘/再注,观测不丢)。
pub fn ui_kinds(shared: &Arc<UiShared>, dm_rt: bool) -> Vec<PlugKind> {
    let mut kinds: Vec<PlugKind> = default_kinds()
        .into_iter()
        .filter(|k| k.name != "dm")
        .collect();
    kinds.push(PlugKind {
        name: "dm",
        doc: "数据管理器(KlineManager;real_time 更新门开,行情板/K线页取 eBarUpdate./eBar.)",
        build: Arc::new(move || -> Arc<dyn Plugin> {
            Arc::new(ek_dm::plugin::DmPlugin::new(DmConfig {
                real_time: dm_rt,
                ..DmConfig::minute("dm")
            }))
        }),
        build_named: None,
        name_validator: None,
    });
    kinds.push(PlugKind {
        name: UI_NAME,
        doc: "常驻观测界面(eLog + eTimer 1s 快照;关窗即 stop)",
        build: {
            let shared = Arc::clone(shared);
            Arc::new(move || -> Arc<dyn Plugin> { Arc::new(UiPlugin::new(Arc::clone(&shared))) })
        },
        build_named: None,
        name_validator: None,
    });
    kinds
}

/// 宿主观察线程:窗口不复用的引擎在这里按 stop 位收场(close_all join)。
///
/// 纪律:close 顺序也让宿主自己走(逆 start 序),观察线程只送信号不代走步骤。
pub fn spawn_host_watcher(
    engine: &Arc<MainEngine>,
    shared: &Arc<UiShared>,
) -> std::thread::JoinHandle<()> {
    let engine = Arc::clone(engine);
    let shared = Arc::clone(shared);
    std::thread::spawn(move || {
        while !shared.is_stop() {
            std::thread::sleep(Duration::from_millis(120));
        }
        shared.log_line("[host] 窗口关闭,close_all …");
        engine.close_all(Duration::from_secs(3));
        shared.log_line("[host] 已收场");
    })
}

/// 档案包操作(导出/导入)一行回执文本;根不可用 = 显形不炸。
fn bundle_op(shared: &Arc<UiShared>, engine: &MainEngine, dir: &str, export: bool) -> String {
    let host = crate::faces::CfgBridge::new(shared);
    let dest = std::path::Path::new(dir);
    if dir.trim().is_empty() {
        return "目录为空(先填路径)".to_string();
    }
    if export {
        let Some(root) = host.cfg.as_ref().map(|c| c.root().to_path_buf()) else {
            return "导出失败:档案托底根不可用".to_string();
        };
        match crate::pluginwin::export_bundle(&root, dest) {
            Ok((c, s)) => {
                let msg = format!("导出完成:{c} 段 → {}(跳过非段文件 {s})", dest.display());
                shared.log_line(format!("[ui] {msg}"));
                msg
            }
            Err(e) => format!("导出失败:{e}"),
        }
    } else {
        let lines = crate::pluginwin::import_bundle(&host, dest, &|ns, sec| {
            crate::faces::apply_section(engine, ns, sec)
        });
        let msg = lines.join(" | ");
        shared.log_line(format!("[ui] 档案包导入:{msg}"));
        msg
    }
}

/// eframe App(窗口面):只读 shared 渲染 + 1s 节流重绘。
struct StationApp {
    shared: Arc<UiShared>,
    /// 宿主引擎(0.9.2「插件即 App」:挂载表按装配名单走)。
    engine: Arc<MainEngine>,
    /// 插件面状态包(各面自态 + 通用窗口态)。
    fs: crate::faces::FaceStates,
    /// 逐插件窗的 X 关合记忆(0.9.3;注册名 → 开)。
    wins_open: std::collections::HashMap<String, bool>,
    /// 「参数面」组钮的上帧态(组沿开时逐窗复位)。
    pluginwin_prev: bool,
    /// 档案包窗口目录草稿与回执。
    bundle_dir: String,
    bundle_notice: Option<String>,
}

impl eframe::App for StationApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        let host = crate::faces::CfgBridge::new(&self.shared);
        let mounted = crate::faces::mounted(&self.engine);
        // 「参数面」钮开 = 整组挂载窗亮;逐窗 X 关合**持久**(wins_open 记账,
        // 0.9.3 修正:此前开合旗随帧丢弃,X 关了下一帧又开)。组开沿时逐窗复位。
        let pw = self.shared.pluginwin_open();
        if pw && !self.pluginwin_prev {
            self.wins_open.clear();
        }
        self.pluginwin_prev = pw;
        if pw {
            for (name, title) in mounted.iter() {
                let mut open = *self.wins_open.get(name).unwrap_or(&true);
                egui::Window::new(title.as_str())
                    .id(egui::Id::new(format!("pluginwin-{name}")))
                    .open(&mut open)
                    .default_width(460.0)
                    .show(ctx, |ui| {
                        crate::faces::render_face(ui, &self.engine, name, &host, &mut self.fs);
                    });
                self.wins_open.insert(name.clone(), open);
            }
        }
        // 档案包窗口:导出/导入 + 逐段回执显形(口令只到引用,CLI 引导);
        // X 关合同样落回 shared(0.9.3)。
        if self.shared.bundle_open() {
            let mut open = true;
            egui::Window::new("档案包")
                .id(egui::Id::new("bundlewin"))
                .open(&mut open)
                .default_width(430.0)
                .show(ctx, |ui| {
                    ui.label("目录(导出目标 / 导入来源,同一目录可两用):");
                    ui.add(
                        egui::TextEdit::singleline(&mut self.bundle_dir)
                            .desired_width(400.0)
                            .hint_text("C:\\Users\\<你>\\Documents\\ek-bundle"),
                    );
                    ui.horizontal(|ui| {
                        if ui.button("导出(段文件整批复制)").clicked() {
                            self.bundle_notice =
                                Some(bundle_op(&self.shared, &self.engine, &self.bundle_dir, true));
                        }
                        if ui.button("导入(逐段收编+受理)").clicked() {
                            self.bundle_notice = Some(bundle_op(
                                &self.shared,
                                &self.engine,
                                &self.bundle_dir,
                                false,
                            ));
                        }
                    });
                    if let Some(n) = &self.bundle_notice {
                        ui.label(egui::RichText::new(n.as_str()).monospace().small());
                    }
                    ui.label(
                        egui::RichText::new("密码不落包(在 Windows 凭据管理器);换机后口令走 ekapp add-account(stdin)")
                            .small()
                            .weak(),
                    );
                });
            self.shared.set_bundle_open(open);
        }
        // 面板组合在 render_body 内(官方纪律:CentralPanel 最后;BD-UI-29)。
        crate::render_body(ctx, &self.shared);
        ctx.request_repaint_after(Duration::from_secs(1));
    }
}

/// 运行选项(S1c ready 标记 + S4 dm real_time 装配参数;ek-ui 零配置口径,
/// 无任何配置文件输入 —— 结构只由 StationConfig 清单决定)。
#[derive(Debug, Clone)]
pub struct RunOpts {
    /// launcher 协议:`start 成功后写 "ready\n"` 的标记路径。
    pub ready_marker: Option<std::path::PathBuf>,
    /// dm 工厂 real_time 门(装配参数,缺省 true —— UI 宿主观测面要实时门)。
    pub dm_rt: bool,
}

impl Default for RunOpts {
    /// 缺省:无 ready 标记 + **dm_rt = true**(UI 宿主缺省实时门;
    /// 勿改回 derive(Default) —— bool 缺省 false 会静默关实时门)。
    fn default() -> Self {
        Self {
            ready_marker: None,
            dm_rt: true,
        }
    }
}

impl RunOpts {
    /// 空选项(无 ready 标记)。
    pub fn plain() -> Self {
        Self::default()
    }
}

/// 进程主入口:装配(dm/oms/ctp/ui 按清单)→ start → 就绪标记(可选)
/// → eframe 主循环 → 观察线程收场。
pub fn run_station_ui(cfg: &StationConfig, opts: &RunOpts) -> Result<(), String> {
    let engine = Arc::new(MainEngine::new(None));
    let shared = Arc::new(UiShared::new());

    // 配置托底根(0.6.0/BD-CFG 环节8):UI 宿主与 headless 同口径 ——
    // `Store::eco()` 成功 → `boot_with_cfg`(开机逐插件 apply_persisted,
    // 受理三行序在插件 worker 面);eco 失败(平台缺配置目录,罕见)→
    // 退回 `boot()`(cfg 收件壳仍在场,fallback 直写仍通,UI 行为即旧版零变)。
    let store = ek_cfg::Store::eco();
    let boot = match &store {
        Ok(s) => ek_station::boot_with_cfg(&engine, &ui_kinds(&shared, opts.dm_rt), cfg, Some(s)),
        Err(e) => {
            let _ = e; // 根不可用为环境面问题;factories 的 eco_or_local 已显形
            ek_station::boot(&engine, &ui_kinds(&shared, opts.dm_rt), cfg)
        }
    };
    boot.map_err(|e| format!("boot: {e:?}"))?;
    // start 失败也要走 close_all(0.4.3 BD-UI-64,审核 P3;与 ek-station headless
    // main.rs 的 start 失败路径同口径):start 是逐个 launch 的,失败时已有插件在
    // Running —— 不闭就没有 on_stop(网关侧的会话收尾与流目录处理被跳过)。
    // boot 失败不用闭:那时插件只登记未 launch,没有 worker 要收。
    if let Err(e) = engine.start() {
        engine.close_all(Duration::from_secs(3));
        return Err(format!("start: {e:?}"));
    }

    if let Some(marker) = &opts.ready_marker {
        use std::io::Write;
        if let Ok(mut f) = std::fs::File::create(marker) {
            let _ = f.write_all(crate::launch::READY_TEXT.as_bytes());
        } else {
            eprintln!("ek-ui: ready marker write failed: {}", marker.display());
        }
    }

    let watcher = spawn_host_watcher(&engine, &shared);

    let opts = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([1280.0, 860.0])
            .with_title("ek-station"),
        ..Default::default()
    };
    // 关窗即收:run_native 返回 = 窗口已关 → 置 stop → 观察线程 close_all(BD-UI-11)。
    let result = eframe::run_native(
        "ek-station",
        opts,
        Box::new(|cc| {
            crate::fonts::apply_cjk_fonts(&cc.egui_ctx); // BD-UI-26:CJK 回退,中文不乱码
            Ok(Box::new(StationApp {
                shared: Arc::clone(&shared),
                engine: Arc::clone(&engine),
                fs: crate::faces::FaceStates::new(),
                wins_open: std::collections::HashMap::new(),
                pluginwin_prev: false,
                bundle_dir: String::new(),
                bundle_notice: None,
            }) as Box<dyn eframe::App>)
        }),
    );
    shared.request_stop();
    let _ = watcher.join();
    result.map_err(|e| format!("ui: {e:?}"))
}