跳转至

crates\nfarms-gui\src\plugins\ui.rs

源码路径:crates\nfarms-gui\src\plugins\ui.rs · 478 行

//! 页面里共用的小控件。
//!
//! 从原来的 lib.rs 里搬出来的 —— 页面拆成插件之后,这些小东西需要一个
//! 大家都能用的落脚点,而不是继续挂在 `App` 上。

// **这里没有模块级的 `#![allow(dead_code)]`。**
//
// 以前有,代价是这个文件里任何一个控件没人用了,编译器都不会说一声 ——
// 「无法从编译器得知哪个控件已无人使用」正是审计里那一条。
// 真正给**以后**的页面预留的少数几个,在各自定义上单独标
// `#[expect(dead_code)]`(而不是 `#[allow]`):`expect` 会在它终于被接上的
// 那天变成「未兑现的预期」并报警,提醒把这行删掉;`allow` 则会永远沉默。

use eframe::egui::{self, Color32, RichText};

use crate::icons;
use crate::pages::PageInfo;
use crate::theme;

/// 把 Markdown 的粗体标记去掉。
///
/// 页面元信息(`PageInfo.note`)里为了在 README / 源码里好读,写了 `**重点**`;
/// 但 egui 的 RichText 不认 Markdown,直接画出来就是两个星号露在外面。
/// 渲染前统一走一遍这个函数 —— 比要求所有人别写星号靠谱。
/// 去掉轻量 markdown 的 `**` 标记,给 `RichText::new` 用。
///
/// # 为什么需要
///
/// 页面里的说明文字习惯写成 `**重点**`,但 **egui 的 `RichText` 不解析
/// markdown** —— 不处理的话星号会**原样显示**出来。而 `RichText::strong()`
/// 也不解决问题:egui 自带字体没有粗体字重,`strong()` 只是换个颜色。
///
/// 所以这里把标记去掉。**强调靠字号和颜色**(调用点已经有 `.size()` /
/// `.color(theme::TEXT_SUB)`),而不是靠字重 —— 那才是 egui 里能真正
/// 生效的表达方式。
pub fn md(s: &str) -> String {
    s.replace("**", "")
}

/// 与 [`md`] 是**同一个实现**(都只是去掉 `**`)。
///
/// 留着两份是因为收敛要动本文件以外的地方:`plugins/mod.rs` 把它再导出成
/// `plain_text`(弹出窗标题栏用),`core_pages.rs` 还有十几处 `ui::plain(...)`。
///
/// 所以本文件里**不要**再长出第三份 —— `md` / `plain` / `plain_text` 三个名字
/// 已经是同一个语义的三张皮;要做的是把外面的调用点并到 [`md`],再删掉这一份。
pub fn plain(s: &str) -> String {
    s.replace("**", "")
}

/// 中间省略:`C:\很长的目录\…\歌名.mp3`。
///
/// 为什么是**中间**而不是末尾:路径和文件名两头都有用 —— 头是盘符/共享名
/// (判断在哪个盘),尾是文件名(判断是哪一首)。末尾省略会把文件名整段砍掉,
/// 那就等于什么都没说。
///
/// 为什么不换行:这是键值行和列表项,换行会让行高忽大忽小、列表参差不齐。
///
/// `max_chars == 0` 表示**一格都放不下**,返回空串。原来的写法是返回原文,
/// 但「宽度为 0」的调用方要的是「别显示」,拿到全文的结果是**真的整段画出来**
/// (0 宽的格子挡不住它)—— 那比少显示更糟,所以这里定成空串。
pub fn ellipsize(s: &str, max_chars: usize) -> String {
    if max_chars == 0 {
        return String::new();
    }
    let n = s.chars().count();
    if n <= max_chars {
        return s.to_string();
    }
    let budget = max_chars.saturating_sub(1); // 省略号自己占一个
    let head_len = budget / 2;
    let tail_len = budget - head_len;
    let head: String = s.chars().take(head_len).collect();
    let tail: String = s.chars().skip(n - tail_len).collect();
    format!("{head}…{tail}")
}

/// 单行文本标签,**按可用宽度自动中间省略**,完整内容放悬停提示。
///
/// **目前只有 [`kv_one_line`] 走这条路**(音乐页的「当前」)。别处那几处
/// `ui::ellipsize(x, 60) / (x, 70) / (x, 72)` 是写死字数的旧写法,还没收编
/// 过来 —— 窗口一改宽窄那几个数字就不对了,别再照抄;要改它们得动
/// `core_pages.rs`(不在本文件里)。
///
/// 直接 `ui.label(RichText::new(path))` 会把卡片撑破,长到一定程度
/// egui 还会把它画到卡片外面去(音乐页的「当前」和播放列表都这么炸过)。
///
/// 没有 `mono` 参数:原签名有个 `mono: bool`,但全项目没有任何调用点传
/// `true`(唯一调用者 [`kv_one_line`] 写死 `false`)—— 没人用的分支也就是
/// 没人测的分支,删掉;真有等宽需求时再加回来。
pub fn one_line(ui: &mut egui::Ui, text: &str, size: f32) -> egui::Response {
    // 按可用宽度估能放几个字。中文字宽约等于字号,西文约 0.55 倍 ——
    // 取 0.62 略偏保守,宁少放几个字也不要溢出。
    //
    // 下面两个下限是**有意**的,不是漏改的魔数:
    // * `.max(40.0)`:容器被压到 40px 以下时照实算只能放 0~5 个字,那时连
    //   「a…b」都摆不下;按 40px 算至少还能把省略号本身画出来。
    // * `.max(8)`:最少留 8 个字位 —— 宁可稍微溢出,也不给一句只剩「…」的
    //   残句(读不出是哪个文件)。主窗口最小宽度 1060px(见 lib.rs),
    //   正常布局下这两个下限都不会触发。
    let avail = ui.available_width().max(40.0);
    let max_chars = ((avail / (size * 0.62)).floor() as usize).max(8);
    let shown = ellipsize(text, max_chars);
    let rt = RichText::new(shown).size(size).color(theme::TEXT_MAIN);
    ui.label(rt).on_hover_text(text)
}

/// 「键值行」左边那一列的宽度。
///
/// [`kv`] 与 [`kv_one_line`] **共用这一个值**:两者是同一个排版概念,原来一边
/// 92.0、一边 72.0,于是同一张卡片里相邻的两行(音乐页的「当前」+「状态」)
/// 标签列会错开 20px。这个差异没有理由 —— 要变就一起变。
const KEY_COL_W: f32 = 92.0;

/// 「键值行」右边那个值的字号,同样两种排版共用(原来 12.5 / 12.0)。
const VALUE_SIZE: f32 = 12.5;

/// 键值行,值那一侧会自动省略(长路径专用)。
pub fn kv_one_line(ui: &mut egui::Ui, key: &str, value: &str) {
    ui.horizontal(|ui| {
        ui.add_sized(
            [KEY_COL_W, 18.0],
            egui::Label::new(RichText::new(key).size(12.0).color(theme::TEXT_SUB)),
        );
        // 先占掉 key 那一列的宽度再算可用宽度,不然会多算一个 KEY_COL_W
        ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
            one_line(ui, value, VALUE_SIZE);
        });
    });
}

/// 画一张雾玻璃卡片。
///
/// **强制撑满容器宽度** —— 这是「所有雾玻璃模块统一大小(高度不管)」的关键。
///
/// 不做这件事的话 `Frame` 会按**内容**自适应:内容里有长 URL 的卡片被撑满,
/// 内容短的(一行文字加个按钮)就缩成窄条,同一页上两张卡片宽窄不一。
/// 全项目 85 处调用都在这里统一,不用每页各自去 `set_min_width`。
pub fn card<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
    // **用 `max_rect()` 而不是 `available_width()`** —— 这是关键。
    //
    // `available_width()` 是「**剩余**可用宽度」,会被同一容器里前面内容横向
    // 占掉的空间影响。ffmpeg 页就是活例子:第一张卡片的内容里有超长不可折行的
    // URL,它把容器「已用宽度」顶上去之后,第二张卡片量到的 available_width
    // 就变小了 —— 于是同一页上两张卡片宽窄不一,而每张单独看都"有理"。
    // `max_rect()` 是容器**整块**的矩形,与已经用掉多少无关。
    //
    // 这个坑是我第一次改的时候踩的:当时用 available_width(),bilibili-go 页
    // 看起来对了,ffmpeg 页却依旧不齐 —— 因为两页的"前面占了多宽"不同。
    let w = ui.max_rect().width();
    // 横向滚动区里 max_rect 可能是无穷大,那种情况下不能拿它当目标
    let w = if w.is_finite() && w > 0.0 {
        w
    } else {
        ui.available_width()
    };
    // 卡片内宽 = 容器宽 - **这张 Frame 自己吃掉的水平边距**。
    //
    // 不写死 32:那个数字只对「inner_margin = 16 且没有描边」才成立,而
    // `theme::card_frame()` 的描边宽 1.0 也算进 `Frame::total_margin()`
    // (左右各 16 + 各 1 = 34)。写死 32 的真正毛病不是差这 2px,而是谁把
    // `card_frame` 的 inner_margin 改成 20,这里不会报错,只会静静多出 8px。
    // 从 frame 身上量:改 theme 就自动跟着走,测试 `card_content_width_...`
    // 把「内宽 == 容器宽 - frame 边距」这个关系钉住了。
    let frame = theme::card_frame();
    let inner = (w - frame.total_margin().sum().x).max(80.0);
    frame
        .show(ui, |ui| {
            // **min 和 max 都要设** —— 只设 min 挡不住「内容超宽把卡片撑开」:
            // 长备注、超长路径、不可折行的 URL 都会把 Frame 推得比容器还宽,
            // 于是同一页上有的卡片宽、有的窄。锁住 max 之后内容会**折行**,
            // 卡片宽度才真正统一。这一条是看用户截图才想明白的:
            // 第一次只加 min 时我以为搞定了,其实只是"最窄不低于容器"而已。
            ui.set_min_width(inner);
            ui.set_max_width(inner);
            add(ui)
        })
        .inner
}

/// 卡片里的一行「标题 + 灰字说明」。
pub fn section(ui: &mut egui::Ui, title: &str, subtitle: &str) {
    ui.label(RichText::new(title).size(15.0).color(theme::TEXT_MAIN));
    if !subtitle.is_empty() {
        ui.label(RichText::new(subtitle).size(12.0).color(theme::TEXT_SUB));
    }
}

/// 「左边一个灰标签、右边一个值」的一行。
///
/// 标签列宽与值的字号和 [`kv_one_line`] 共用同一批常量(见 [`KEY_COL_W`]),
/// 两者只是「值会不会自动省略」的区别。
pub fn kv(ui: &mut egui::Ui, key: &str, value: &str) {
    ui.horizontal(|ui| {
        ui.add_sized(
            [KEY_COL_W, 18.0],
            egui::Label::new(RichText::new(key).size(12.0).color(theme::TEXT_SUB)),
        );
        ui.label(RichText::new(value).size(VALUE_SIZE).color(theme::TEXT_MAIN));
    });
}

/// 还没移植的页面。
///
/// 不写「敬请期待」,而是明确交代原版在哪个文件、多少行、实现要点是什么 ——
/// 这个界面本身就是进度表。
///
/// **当前只有两条测试/预留路径会走到这里**:`stubs::placeholder`(只被
/// `plugins/mod.rs` 的测试用到)和 `core_pages::harness`(已不挂在任何页面上)。
/// 34 页全有真实现之后,它不再是任何页面的渲染器 —— 留着是给「以后新加一页
/// 先占个位」用的。所以这里用 `expect` 而不是 `allow`:哪天它真被接上,
/// 编译器会提醒把这行删掉。
///
/// 为什么套一层 `cfg_attr(not(test))`:测试构建里 `stubs::placeholder` 真的被
/// 用到,`dead_code` 不会报 —— 那时 `expect` 就成了「未兑现的预期」而自己报警。
/// 只在非测试构建里要求它必须兑现。
#[cfg_attr(not(test), expect(dead_code))]
pub fn placeholder_body(ui: &mut egui::Ui, info: &PageInfo) {
    card(ui, |ui| {
        ui.horizontal(|ui| {
            let (rect, _) = ui.allocate_exact_size(egui::vec2(28.0, 28.0), egui::Sense::hover());
            icons::draw(ui.painter(), rect, info.icon, theme::ACCENT);
            ui.label(
                RichText::new("这一页还没移植")
                    .size(16.0)
                    .color(theme::TEXT_MAIN),
            );
        });
        ui.add_space(10.0);
        ui.label(RichText::new("来源").size(12.0).color(theme::TEXT_SUB));
        ui.label(
            RichText::new(info.origin())
                .size(13.0)
                .color(theme::TEXT_MAIN),
        );
        ui.add_space(10.0);
        ui.label(RichText::new("实现要点").size(12.0).color(theme::TEXT_SUB));
        ui.label(
            RichText::new(plain(info.note))
                .size(13.0)
                .color(theme::TEXT_MAIN),
        );
    });
}

/// 一条绿色的小提示(「已保存」「已启动」这类成功反馈)。
pub fn ok_hint(ui: &mut egui::Ui, text: &str) {
    ui.label(RichText::new(text).size(12.0).color(theme::ACCENT_DARK));
}

/// 一条红色的错误提示。
pub fn err_hint(ui: &mut egui::Ui, text: &str) {
    ui.label(RichText::new(text).size(12.0).color(theme::DANGER));
}

/// 一行状态 + 是否就绪。用于「装没装 / 下没下」这类判断的展示。
pub fn status_pill(ui: &mut egui::Ui, ready: bool, text: &str) {
    let color = if ready { theme::ACCENT_DARK } else { theme::TEXT_SUB };
    ui.label(RichText::new(text).size(12.0).color(color));
}

/// 主按钮(实心品牌绿)。
pub fn primary_button(ui: &mut egui::Ui, text: &str, size: [f32; 2]) -> bool {
    ui.add_sized(
        size,
        egui::Button::new(RichText::new(text).color(Color32::WHITE)).fill(theme::ACCENT),
    )
    .clicked()
}

/// 字节数 → `12.3 GB` / `812 MB` / `2.0 TB`。
///
/// **1024 进制,但文案用 Windows 口径的 KB/MB/GB**(不写成 KiB/MiB/GiB):
/// 同一个界面上 `nfarms_core::cyinfo::human`、`libs::describe` 都是这个口径,
/// 只把这一处改成 GiB,会让「磁盘 465.7 GiB」和「内存 16.0 GB」并排出现。
/// 要改成 IEC 单位得那两处一起动(都不在本文件里)。
///
/// 单位是按 1024 的幂**逐级挑**的:以前只有 GB/MB/KB 三档,2 TiB 的磁盘
/// 会显示成 `2048.0 GB`(磁盘占用/剩余空间这类值很容易过 1 TiB)。
pub fn human_bytes(bytes: u64) -> String {
    const PB: f64 = 1_125_899_906_842_624.0; // 1024^5
    const TB: f64 = 1_099_511_627_776.0; // 1024^4
    const GB: f64 = 1_073_741_824.0; // 1024^3
    const MB: f64 = 1_048_576.0; // 1024^2
    let b = bytes as f64;
    if b >= PB {
        format!("{:.1} PB", b / PB)
    } else if b >= TB {
        format!("{:.1} TB", b / TB)
    } else if b >= GB {
        format!("{:.1} GB", b / GB)
    } else if b >= MB {
        format!("{:.0} MB", b / MB)
    } else {
        format!("{:.0} KB", b / 1024.0)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// 画一帧,把「画面上出现过的每一段文字 + 它的 x」收出来。
    ///
    /// 为什么不用 `__run_test_ui`:那个只给一个 `&mut Ui`、不还整帧的输出,
    /// 而下面两条测试要看的正是**真实排版结果**(两行对不对得齐、卡片内宽
    /// 到底是多少)。这些数字只有把帧跑出来、从 shape 列表里量才看得见 ——
    /// 复述一遍代码里的常量是证明不了任何事的。
    fn painted_text_x(run: impl FnMut(&egui::Context)) -> Vec<(String, f32)> {
        let ctx = egui::Context::default();
        // 不加载字体:这里量的都是显式宽度,和字形无关,省掉几 MB 字体解析
        ctx.set_fonts(egui::FontDefinitions::empty());
        let out = ctx.run(egui::RawInput::default(), run);
        out.shapes
            .iter()
            .filter_map(|cs| match &cs.shape {
                egui::Shape::Text(t) => Some((t.galley.text().to_owned(), t.pos.x)),
                _ => None,
            })
            .collect()
    }

    #[test]
    fn human_bytes_picks_a_sane_unit() {
        assert_eq!(human_bytes(0), "0 KB");
        assert_eq!(human_bytes(1024 * 900), "900 KB");
        assert_eq!(human_bytes(5 * 1024 * 1024), "5 MB");
        assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GB");
    }

    /// 单位要能一路挑到 TB/PB。
    ///
    /// 复发场景:回到只有 GB/MB/KB 三档的实现 —— 2 TiB 的磁盘会显示成
    /// `2048.0 GB`(磁盘占用/剩余这类值很容易过 1 TiB,见 core_pages 的调用点)。
    #[test]
    fn human_bytes_goes_past_gb() {
        assert_eq!(
            human_bytes(2 * 1024_u64.pow(4)),
            "2.0 TB",
            "2 TiB 不该显示成 2048.0 GB"
        );
        assert_eq!(human_bytes(1024_u64.pow(4)), "1.0 TB", "TB 的边界");
        assert_eq!(human_bytes(1024_u64.pow(4) - 1), "1024.0 GB", "TB 边界下方仍是 GB");
        assert_eq!(human_bytes(3 * 1024_u64.pow(5)), "3.0 PB");
    }

    /// `max_chars == 0`(**一格都放不下**)必须什么都不显示。
    ///
    /// 复发场景:回到「0 就直接返回原文」—— 调用方用 0 表达「别显示」时,
    /// 拿到的是**整段文本**,而 0 宽的格子挡不住它,于是长路径照旧画出去。
    #[test]
    fn ellipsize_zero_width_shows_nothing() {
        assert_eq!(ellipsize("C:\\很长的路径\\一首歌.mp3", 0), "");
        // 正常路径不受影响:两头各留一半,中间一个省略号;装得下就原样返回
        assert_eq!(ellipsize("abcdefgh", 5), "ab…gh");
        assert_eq!(ellipsize("abc", 5), "abc");
    }

    /// `one_line` 真的按可用宽度中间省略了,而不是把全文画出去。
    ///
    /// 复发场景:把里面那次 `ellipsize` 去掉(或让 `ellipsize` 原样返回)——
    /// 长文件名就会把卡片顶破,正是审计里「音乐页的『当前』炸过」那件事。
    #[test]
    fn one_line_ellipsizes_to_the_available_width() {
        const LONG: &str = "C:\\很长的目录名\\再一层目录\\一首名字也很长的歌.mp3";
        const SIZE: f32 = 12.0;
        // 29 个字按 0.62 的系数要 216px 才放得下;给 160px,它就一定放不下 ——
        // 这样测的才是「省略」,而不是「原样返回」。
        const NARROW: f32 = 160.0;
        let avail = std::cell::Cell::new(0.0_f32);
        let painted = painted_text_x(|ctx| {
            egui::CentralPanel::default().show(ctx, |ui| {
                // 用 `UiBuilder::max_rect` 而不是 `ui.set_max_width`:后者只动
                // placer 的上限,`available_width()` 仍按外层容器的整宽算
                // (实测 9984),长文本于是"放得下",根本不会走省略那条路。
                let rect = egui::Rect::from_min_size(ui.min_rect().min, egui::vec2(NARROW, 200.0));
                ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| {
                    avail.set(ui.available_width());
                    one_line(ui, LONG, SIZE);
                });
            });
        });
        let avail = avail.get();

        let shown: Vec<&String> = painted
            .iter()
            .map(|(text, _)| text)
            .filter(|text| text.as_str() != LONG)
            .collect();
        assert_eq!(
            shown.len(),
            1,
            "应当只画了一段缩短后的文本(可用宽度 {avail}):{painted:?}"
        );
        let shown = shown[0].as_str();
        assert!(shown.contains('…'), "长文本没有被中间省略:{shown}");
        assert!(shown.chars().count() < LONG.chars().count());
        // 画出来的长度要真的塞得进可用宽度(按最小字宽 0.55 估,最乐观的算法)
        assert!(
            shown.chars().count() as f32 * SIZE * 0.55 <= avail,
            "省略后的 {shown:?} 仍然宽于可用宽度 {avail}"
        );
    }

    /// `kv` 与 `kv_one_line` 的「值」必须从**同一列**开始。
    ///
    /// 复发场景(审计原样):两处各留一个魔数(92 / 72)。音乐页同一张卡片里
    /// `kv_one_line("当前", …)` 紧跟着 `kv("状态", …)`,标签列一宽一窄,两行的
    /// 值就错开 20px —— 编译器、类型系统都不会吭声,只有截图能看出来。
    #[test]
    fn kv_rows_share_one_key_column() {
        let rows = std::cell::RefCell::new(Vec::<f32>::new());
        let painted = painted_text_x(|ctx| {
            egui::CentralPanel::default().show(ctx, |ui| {
                rows.borrow_mut().push(ui.next_widget_position().x);
                kv(ui, "甲", "值一");
                rows.borrow_mut().push(ui.next_widget_position().x);
                kv_one_line(ui, "乙", "值二");
            });
        });
        let rows = rows.into_inner();
        assert_eq!(rows.len(), 2, "两行都该被画出来");

        let x_of = |needle: &str| -> Vec<f32> {
            painted
                .iter()
                .filter(|(text, _)| text == needle)
                .map(|(_, x)| *x)
                .collect()
        };
        let (x_value, x_value_one_line) = {
            let a = x_of("值一");
            let b = x_of("值二");
            assert_eq!(a.len(), 1, "「值一」应当只画了一次");
            assert_eq!(b.len(), 1, "「值二」应当只画了一次");
            (a[0], b[0])
        };

        let offset = |value_x: f32, row_x: f32| value_x - row_x;
        let off_kv = offset(x_value, rows[0]);
        let off_kv_one_line = offset(x_value_one_line, rows[1]);
        assert!(
            (off_kv - off_kv_one_line).abs() < 0.5,
            "两种键值行的值没有对齐:kv 偏移 {off_kv},kv_one_line 偏移 {off_kv_one_line}"
        );
        assert!(
            off_kv >= KEY_COL_W - 0.5,
            "值没有落在标签列({KEY_COL_W})之后:偏移 {off_kv}"
        );
    }

    /// 卡片内宽必须由 `theme::card_frame()` 的边距推出来,不能写死。
    ///
    /// 复发场景:把 `w - frame.total_margin().sum().x` 改回 `w - 32.0`。
    /// 现在 frame 的总边距是 34(inner_margin 左右各 16 + 描边各 1),
    /// 写死 32 会让卡片比容器宽 2px;等谁把 inner_margin 调成 20,这个差
    /// 就变成 8px —— 全程不报错,只有截图能看出来。
    #[test]
    fn card_content_width_follows_the_frame_margin() {
        // 用 `Cell` 而不是 `let mut`:`__run_test_ui` 收的是 `impl Fn`,
        // 闭包里改动外面的变量只能走内部可变性。
        let outer = std::cell::Cell::new(0.0_f32);
        let inner = std::cell::Cell::new(f32::NAN);
        egui::__run_test_ui(|ui| {
            ui.set_max_width(400.0);
            outer.set(ui.max_rect().width());
            card(ui, |ui| inner.set(ui.available_width()));
        });
        let (outer, inner) = (outer.get(), inner.get());
        let margin = theme::card_frame().total_margin().sum().x;
        let expected = (outer - margin).max(80.0);
        assert!(
            (inner - expected).abs() < 0.5,
            "卡片内宽 {inner} != 容器宽 {outer} - frame 边距 {margin} = {expected}"
        );
    }
}