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}"
);
}
}