ek-ui 0.2.21

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
//! pick 窗体:纯渲染入口(L2/kittest 目标)+ `PickApp`(eframe App 状态机)。
//!
//! 渲染无副作用(回调注入);启动动作由 [`crate::launcher`] 主流程接线。
//! kittest 策略:`Harness::new_ui_state(State = Vec<bool>)` —— 点击即翻位,
//! 窗口自闭环。

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

use ek_station::default_kinds;

use crate::launcher::{POLL, RunShared, launch_worker};
use crate::plugin::UI_NAME;

/// 启动按钮标签(测试与 UI 同源)。
pub const BTN_LAUNCH: &str = "启动";
/// 取消按钮标签。
pub const BTN_CANCEL: &str = "取消";
/// 无选择时的禁用提示文案(摘要行)。
const PICKED_NONE: &str = "已选: (无)";

/// pick 表:ek-station 缺省工厂(0.1.12 起 = dm/oms/ctp 三项,sim 已退场)+ ui 工厂(名称,一行说明)。
#[must_use]
pub fn pick_kinds() -> Vec<(String, String)> {
    let mut list = default_kinds()
        .into_iter()
        .map(|k| (k.name.to_string(), k.doc.to_string()))
        .collect::<Vec<_>>();
    list.push((UI_NAME.to_string(), "常驻观测界面(加载即开窗)".to_string()));
    list
}

/// 画 pick 窗口体(不建窗;调用方自定容器)。
///
/// `selected.len()` 必须与 `kinds.len()` 同长(调用方初始化);空选启动钮禁用。
pub fn render_pick(
    ui: &mut egui::Ui,
    selected: &mut [bool],
    kinds: &[(String, String)],
    on_launch: &mut dyn FnMut(),
    on_cancel: &mut dyn FnMut(),
) {
    ui.heading("ek-station 启动选择");
    ui.label("勾选要装载的插件;启动写入清单并拉起 ek-ui(ui 自动补上)。");
    ui.separator();

    egui::ScrollArea::vertical()
        .max_height(240.0)
        .show(ui, |ui| {
            for (i, (name, doc)) in kinds.iter().enumerate() {
                ui.horizontal(|ui| {
                    ui.checkbox(&mut selected[i], name);
                    let _ = ui.label(doc.clone());
                });
            }
        });

    ui.separator();
    let picked: Vec<String> = kinds
        .iter()
        .zip(selected)
        .filter(|(_, ok)| **ok)
        .map(|(k, _)| k.0.clone())
        .collect();
    if picked.is_empty() {
        ui.label(PICKED_NONE);
    } else {
        ui.label(format!("已选: {}", picked.join(", ")));
    }
    ui.horizontal(|ui| {
        if ui
            .add_enabled(!picked.is_empty(), egui::Button::new(BTN_LAUNCH))
            .clicked()
        {
            on_launch();
        }
        if ui.button(BTN_CANCEL).clicked() {
            on_cancel();
        }
    });
}

/// pick 窗 App(UI 线程):勾选 → 按钮/取消 → 触发 worker → 见果关窗。
pub struct PickApp {
    kinds: Vec<(String, String)>,
    selected: Vec<bool>,
    program: PathBuf,
    shared: Arc<RunShared>,
    spawned: bool,
    cancel: bool,
}

impl PickApp {
    /// 全量可勾选 + 全不勾初始态。
    pub fn new(program: PathBuf, shared: Arc<RunShared>) -> Self {
        let kinds = pick_kinds();
        let selected = vec![false; kinds.len()];
        Self {
            kinds,
            selected,
            program,
            shared,
            spawned: false,
            cancel: false,
        }
    }
}

impl eframe::App for PickApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        crate::render::apply_ui_style(ctx); // BD-UI-32:选单窗与主窗同字号(整体口径)
        let kinds = self.kinds.clone();
        let mut req_launch = false;
        let mut req_cancel = false;
        egui::CentralPanel::default().show(ctx, |ui| {
            crate::launcher_ui::render_pick(
                ui,
                &mut self.selected,
                &kinds,
                &mut || req_launch = true,
                &mut || req_cancel = true,
            );
        });

        if req_cancel {
            self.cancel = true;
        }
        if req_launch && !self.spawned {
            let picked: Vec<String> = self
                .kinds
                .iter()
                .zip(&self.selected)
                .filter(|(_, ok)| **ok)
                .map(|(k, _)| k.0.clone())
                .collect();
            if !picked.is_empty() {
                self.spawned = true;
                let shared = Arc::clone(&self.shared);
                let program = self.program.clone();
                let worker_names = picked;
                let h = std::thread::spawn(move || {
                    launch_worker(&shared, &worker_names, program);
                });
                *self.shared.handle.lock().unwrap() = Some(h);
            }
        }

        if self.spawned {
            let done = self.shared.outcome.lock().unwrap().is_some();
            if done {
                ctx.send_viewport_cmd(egui::ViewportCommand::Close); // 收场交给 run_pick_launcher
            } else {
                ctx.request_repaint_after(POLL);
            }
        }
        if self.cancel {
            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
        }
        if !self.spawned {
            ctx.request_repaint_after(Duration::from_millis(500));
        }
    }
}