ek-ui 0.6.2

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
//! CJK 字体回退(BD-UI-26):epaint 0.33 缺省字体组(Hack / Ubuntu-Light /
//! NotoEmoji-Regular / emoji-icon-font)**没有任何中文字形** —— 界面中文
//! 直接渲染成豆腐块/乱码(2026-10-18 用户实窗报障)。
//!
//! 修法:窗口启动时按候选表从**系统字体目录**加载一枚 CJK 字体,追加到
//! Proportional 与 Monospace 两族的**末尾**作回退 —— 拉丁字符仍走缺省字体,
//! 中文落系统字体。零第三方依赖(字体文件读自系统,不随仓库分发);候选全空
//! 则保持缺省(拉丁正常,中文显形环境问题,日志侧记一行)。
//!
//! 解析纪律(复审钉):epaint 0.33 走 **ab_glyph**(ttf-parser 底),对解析
//! 失败的文件是 **panic**(`Error parsing … TTF/OTF font file`)—— 而 ab_glyph
//! 不吃 .ttc 集合(sfnt 偏移表是 `ttcf`,不是字体表)。故候选**只收纯
//! .ttf/.otf**(代码侧再设扩展名闸双保险),msyh/simsun/PingFang 等 .ttc
//! 一律不入候选;VF(可变字体)按 ttf-parser 缺省实例解析,可入但排位靠后。

/// 系统 CJK 字体候选(纯 TTF/OTF;Windows 优先;Linux/macOS 兜底)。
const CJK_CANDIDATES: &[&str] = &[
    "C:/Windows/Fonts/simhei.ttf",
    "C:/Windows/Fonts/simkai.ttf",
    "C:/Windows/Fonts/simfang.ttf",
    "C:/Windows/Fonts/simsunb.ttf",
    "C:/Windows/Fonts/NotoSansSC-VF.ttf",
    "/usr/share/fonts/truetype/unifont/unifont.ttf",
    "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
    "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
];

/// 回退字体在 font_data 里的登记名。
pub const CJK_FONT_NAME: &str = "ek-cjk";

/// 给 egui 上下文装 CJK 回退字体。返回命中的字体路径;全空返回 None。
/// 必须在建窗后、首帧渲染前调用(eframe 取帧闭包里做)。
pub fn apply_cjk_fonts(ctx: &egui::Context) -> Option<String> {
    let mut fonts = egui::FontDefinitions::default();
    for path in CJK_CANDIDATES {
        // 扩展名闸(双保险):ab_glyph 只吃 sfnt 单字体表;喂 .ttc = 渲染期 panic。
        let lower = path.to_ascii_lowercase();
        if !(lower.ends_with(".ttf") || lower.ends_with(".otf")) {
            continue;
        }
        let Ok(bytes) = std::fs::read(path) else {
            continue;
        };
        fonts.font_data.insert(
            CJK_FONT_NAME.to_owned(),
            std::sync::Arc::new(egui::FontData::from_owned(bytes)),
        );
        for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
            fonts
                .families
                .entry(family)
                .or_default()
                .push(CJK_FONT_NAME.to_owned());
        }
        ctx.set_fonts(fonts);
        // 装载证据行(无多模态验收口径:窗口进程 stderr 可 grep 到本行 = 实窗已装字体)。
        eprintln!("[ek-ui] CJK 回退字体已装载: {path}");
        return Some(path.to_string());
    }
    eprintln!("[ek-ui] 未找到系统 CJK 字体候选,中文可能显示为豆腐块(环境侧)");
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    /// 装字体不 panic;命中与否随环境(Windows 候选在位则必命中)。
    /// 验收钉在**字形面**:回退字体必须真带 CJK 字形(乱码根因的直接反证)。
    /// 走 `Harness::new(|ctx|)`(Context 直给)+ 真实 run 一帧(fonts 就位)后断言。
    #[test]
    fn f0_apply_cjk_fonts_registers_fallback() {
        let hit_cell = std::cell::RefCell::new(None);
        let recorder = &hit_cell;
        let mut harness = egui_kittest::Harness::new(move |ctx: &egui::Context| {
            let mut slot = recorder.borrow_mut();
            if slot.is_none() {
                *slot = apply_cjk_fonts(ctx);
            }
            drop(slot);
            egui::CentralPanel::default().show(ctx, |ui| {
                ui.label("观测台 插件 行情 委托");
            });
        });
        harness.run();
        let hit = hit_cell.borrow();
        if let Some(path) = &*hit {
            assert!(!path.is_empty());
            let probe = "观测台插件状态版本依赖行情委托成交持仓资金冻结订阅授权密码回灌撤单";
            let font_id = egui::FontId::proportional(16.0);
            let mut missing: Vec<char> = Vec::new();
            harness.ctx.fonts_mut(|f| {
                for ch in probe.chars() {
                    if !f.has_glyph(&font_id, ch) {
                        missing.push(ch);
                    }
                }
            });
            assert!(
                missing.is_empty(),
                "回退字体缺字形: {missing:?} (font: {path})"
            );
        }
    }
}