crates\nfarms-gui\src\icons.rs¶
源码路径:
crates\nfarms-gui\src\icons.rs· 966 行
//! 侧栏用的矢量图标。
//!
//! **为什么不用 Unicode 符号**:原版的 README 专门记了这件事 ——
//! 小窗按钮原来用 `⛶ / ⤢ / 📌` 这些字符,离屏渲染图里一切正常,**真机上被
//! 「字体链接」换成了完全不同的字形**(一排方块键盘似的东西)。GDI 的文字渲染
//! 回退和 GDI+ 不一致,自绘路径从根上没有这个问题。
//!
//! 这里照搬这个结论:图标一律用 painter 画,不依赖任何字体。
//! 功能树每加一页都会带来一个新图标,这份清单只会越来越长;每个图标都只有几行 ——
//! 换来的是「加一页不用去找图标字体」。
//!
//! **刻意不在这里写「N 组 M 页 / K 个图标」这类数字**:这种数字只会在
//! `pages.rs` 扩表时悄悄过期(本注释以前就写死过一个「7 组 30 页」,后来对不上了,
//! 而且没有任何测试会因此变红)。要数字就去数 `pages.rs` 里的 `Group::ALL` 与
//! `PAGES`,以及下面的 [`NAMES`] —— 不要数注释。
use eframe::egui::{self, Color32, Pos2, Rect, Stroke};
/// 全部图标名。新增页面时往这里加一个名字,再在 `draw` 里补一个分支。
///
/// 这份清单**不是只给测试看的**:`draw` 的兜底分支里有一句
/// `debug_assert!(is_known(kind))`,所以在启用 debug 断言的构建里(`cargo test`
/// 与本地 debug 运行),拼错名字会当场断在调用点,而不是静默画成一个小圆点 ——
/// 那种 bug 没人会注意到。因此 [`NAMES`] / [`is_known`] 在运行期确实被读到,
/// 不需要(也不要再补)`#[allow(dead_code)]`。
pub const NAMES: &[&str] = &[
// 分组图标
"tools",
"players",
"model",
"browser",
"environment",
"vpn",
"settings",
// Tools
"tv",
"waveform",
"clock",
"pet",
"script",
// Player
"music",
"film",
"text",
"image",
"live",
// Model
"server",
"chat",
"sparkle",
// Browser
"engine",
"layers",
"bookmark",
"chart",
// Environment
"harness",
// VPN
"topo",
"link",
// Setting
"play-circle",
"printer",
"folder",
"database",
"disk",
"sync",
// 其它
"weather",
"puzzle",
// Slack off
"fish",
"rain",
// VPN 集成
"shark",
"terminal",
"transfer",
];
/// 名字是否登记在 [`NAMES`] 里。`draw` 的兜底分支用它做运行期校验。
pub fn is_known(kind: &str) -> bool {
NAMES.contains(&kind)
}
/// 在 `rect` 里画一个名为 `kind` 的矢量图标。
pub fn draw(painter: &egui::Painter, rect: Rect, kind: &str, color: Color32) {
let c = rect.center();
let s = rect.width().min(rect.height());
let h = s / 2.0;
let stroke = Stroke::new((s * 0.10).max(1.4), color);
// 常用小工具:相对中心取点(倍数 × h)
let p = |dx: f32, dy: f32| egui::pos2(c.x + h * dx, c.y + h * dy);
let line = |a: (f32, f32), b: (f32, f32)| {
painter.line_segment([p(a.0, a.1), p(b.0, b.1)], stroke);
};
/// 以中心为原点、按 h 倍数给出尺寸的矩形边框
fn frame_of(painter: &egui::Painter, c: Pos2, h: f32, w: f32, ht: f32, stroke: Stroke) -> Rect {
let r = Rect::from_center_size(c, egui::vec2(h * w, h * ht));
painter.rect_stroke(r, 1.5, stroke, egui::StrokeKind::Inside);
r
}
match kind {
// ---------------------------------------------------------- 分组
// 扳手:斜手柄 + 开口的头部
"tools" => {
line((-0.62, 0.66), (0.20, -0.20));
painter.circle_stroke(p(0.48, -0.48), h * 0.34, stroke);
line((0.30, -0.62), (0.62, -0.30));
}
// 播放三角(Player 分组)
"players" => {
painter.add(egui::Shape::convex_polygon(
vec![p(-0.5, -0.72), p(0.78, 0.0), p(-0.5, 0.72)],
color,
Stroke::NONE,
));
}
// 芯片(Model 分组)
"model" => {
let r = Rect::from_center_size(c, egui::vec2(h * 1.24, h * 1.24));
painter.rect_stroke(r, 2.0, stroke, egui::StrokeKind::Inside);
painter.rect_filled(
Rect::from_center_size(c, egui::vec2(h * 0.5, h * 0.5)),
1.0,
color,
);
for d in [-0.42_f32, 0.0, 0.42] {
line((d, -0.62), (d, -0.86));
line((d, 0.62), (d, 0.86));
line((-0.62, d), (-0.86, d));
line((0.62, d), (0.86, d));
}
}
// 地球(Browser 分组)
"browser" => {
let r = h * 0.86;
painter.circle_stroke(c, r, stroke);
let mut left = Vec::new();
let mut right = Vec::new();
for i in 0..=12 {
let a = std::f32::consts::TAU * (i as f32) / 12.0;
let (sn, cs) = a.sin_cos();
left.push(egui::pos2(c.x - r * 0.45 * sn, c.y - r * cs));
right.push(egui::pos2(c.x + r * 0.45 * sn, c.y - r * cs));
}
painter.add(egui::Shape::line(left, stroke));
painter.add(egui::Shape::line(right, stroke));
line((-0.86, 0.0), (0.86, 0.0));
}
// 三层堆叠(Environment 分组)
"environment" => {
for (i, w) in [0.62_f32, 0.86, 0.74].iter().enumerate() {
let y = (i as f32 - 1.0) * 0.56;
line((-w, y), (*w, y));
}
}
// 盾牌(Zero Trust VPN 分组)
"vpn" => {
painter.add(egui::Shape::convex_polygon(
vec![
p(0.0, -0.88),
p(0.74, -0.46),
p(0.60, 0.50),
p(0.0, 0.88),
p(-0.60, 0.50),
p(-0.74, -0.46),
],
Color32::TRANSPARENT,
stroke,
));
}
// 齿轮(Setting 分组)
"settings" => {
let r = h * 0.52;
painter.circle_stroke(c, r, stroke);
painter.circle_filled(c, r * 0.30, color);
for i in 0..8 {
let a = (i as f32) * std::f32::consts::TAU / 8.0;
let (sn, cs) = a.sin_cos();
painter.line_segment(
[
egui::pos2(c.x + cs * r * 1.05, c.y + sn * r * 1.05),
egui::pos2(c.x + cs * r * 1.75, c.y + sn * r * 1.75),
],
stroke,
);
}
}
// ---------------------------------------------------------- Tools
// 显示器 + 天线(bilibili-go 这类直播录制)
"tv" => {
let r = frame_of(painter, c, h, 1.6, 1.05, stroke);
line((0.0, (r.top() - c.y) / h), (0.0, -0.86));
line((0.0, -0.86), (-0.42, -0.86));
line((0.0, -0.86), (0.42, -0.86));
painter.rect_filled(
Rect::from_center_size(c, egui::vec2(h * 0.5, h * 0.16)),
1.0,
color,
);
}
// 声波(ffmpeg)
"waveform" => {
for (i, k) in [0.36_f32, 0.72, 1.0, 0.58, 0.84, 0.44].iter().enumerate() {
let x = -0.8 + (i as f32) * 0.32;
line((x, -k * 0.9), (x, k * 0.9));
}
}
// 时钟(时间同步)
"clock" => {
painter.circle_stroke(c, h * 0.84, stroke);
line((0.0, 0.0), (0.0, -0.5));
line((0.0, 0.0), (0.36, 0.12));
for i in 0..12 {
let a = (i as f32) * std::f32::consts::TAU / 12.0;
let (sn, cs) = a.sin_cos();
line((cs * 0.92, sn * 0.92), (cs * 0.72, sn * 0.72));
}
}
// 猫爪(pet)
"pet" => {
painter.circle_filled(p(0.0, 0.30), h * 0.42, color);
for (dx, dy) in [(-0.52_f32, -0.42_f32), (0.0, -0.62), (0.52, -0.42)] {
painter.circle_filled(p(dx, dy), h * 0.21, color);
}
}
// 文件 + 折角(script)
"script" => {
let r = frame_of(painter, c, h, 1.14, 1.5, stroke);
let fold = 0.34;
line(
((r.right() - c.x) / h - fold, (r.top() - c.y) / h),
((r.right() - c.x) / h, (r.top() - c.y) / h + fold),
);
for i in 0..3 {
let y = 0.06 + (i as f32) * 0.34;
line((-0.4, y), (0.4, y));
}
}
// ---------------------------------------------------------- Player
// 音符(music)
"music" => {
painter.circle_filled(p(-0.28, 0.52), h * 0.30, color);
line((-0.02, 0.52), (-0.02, -0.74));
line((-0.02, -0.74), (0.52, -0.52));
line((0.52, -0.52), (0.52, -0.10));
}
// 胶片(video)
"film" => {
let r = frame_of(painter, c, h, 1.5, 1.14, stroke);
line((-0.30, -0.57), (-0.30, 0.57));
line((0.30, -0.57), (0.30, 0.57));
for i in 0..3 {
let y = -0.36 + (i as f32) * 0.36;
for x in [r.left() + h * 0.15, r.right() - h * 0.15] {
painter.rect_filled(
Rect::from_center_size(
egui::pos2(x, c.y + h * y),
egui::vec2(h * 0.14, h * 0.16),
),
0.5,
color,
);
}
}
}
// 文档 + 文本行(reader)
"text" => {
frame_of(painter, c, h, 1.2, 1.5, stroke);
for i in 0..4 {
let y = -0.42 + (i as f32) * 0.30;
let w = if i == 3 { 0.20 } else { 0.42 };
line((-0.42, y), (w, y));
}
}
// 相框 + 山 + 太阳(image)
"image" => {
let r = frame_of(painter, c, h, 1.5, 1.24, stroke);
painter.circle_filled(
egui::pos2(r.left() + h * 0.36, r.top() + h * 0.34),
h * 0.12,
color,
);
painter.add(egui::Shape::line(
vec![
egui::pos2(r.left() + h * 0.10, r.bottom() - h * 0.10),
egui::pos2(c.x - h * 0.10, c.y + h * 0.06),
egui::pos2(c.x + h * 0.16, c.y + h * 0.34),
egui::pos2(c.x + h * 0.46, r.bottom() - h * 0.10),
],
stroke,
));
}
// 信号点 + 两道弧(live)
"live" => {
painter.circle_filled(c, h * 0.24, color);
for r in [0.52_f32, 0.80] {
painter.add(arc(c, h * r, -0.9, 0.9, stroke));
painter.add(arc(
c,
h * r,
std::f32::consts::PI - 0.9,
std::f32::consts::PI + 0.9,
stroke,
));
}
}
// ---------------------------------------------------------- Model
// 机箱(server)
"server" => {
for i in 0..2 {
let y = -0.44 + (i as f32) * 0.60;
let r = Rect::from_center_size(
egui::pos2(c.x, c.y + h * y),
egui::vec2(h * 1.42, h * 0.46),
);
painter.rect_stroke(r, 2.0, stroke, egui::StrokeKind::Inside);
painter.circle_filled(
egui::pos2(r.left() + h * 0.20, r.center().y),
h * 0.09,
color,
);
}
}
// 对话气泡(chat)
"chat" => {
let r = Rect::from_center_size(
egui::pos2(c.x, c.y - h * 0.14),
egui::vec2(h * 1.48, h * 1.0),
);
painter.rect_stroke(r, h * 0.24, stroke, egui::StrokeKind::Inside);
painter.add(egui::Shape::convex_polygon(
vec![
egui::pos2(r.left() + h * 0.30, r.bottom()),
egui::pos2(r.left() + h * 0.66, r.bottom()),
egui::pos2(r.left() + h * 0.26, r.bottom() + h * 0.42),
],
color,
Stroke::NONE,
));
for dx in [-0.34_f32, 0.0, 0.34] {
painter.circle_filled(p(dx, -0.14), h * 0.09, color);
}
}
// 四角星(sparkle · 图像生成)
"sparkle" => {
// 星形是**凹**的,不能整体喂给 `convex_polygon`(它会填错);
// 拆成以星心为公共顶点的凸三角形来画,原因见 `star_triangles`。
for tri in star_triangles(c, h, -0.22, -0.16, 0.72)
.into_iter()
.chain(star_triangles(c, h, 0.56, 0.56, 0.34))
{
painter.add(egui::Shape::convex_polygon(
tri.to_vec(),
color,
Stroke::NONE,
));
}
}
// ---------------------------------------------------------- Browser
// 引擎:齿轮 + 实心内圈(和 Setting 的齿轮靠内圈区分)
"engine" => {
let r = h * 0.56;
painter.circle_stroke(c, r, stroke);
for i in 0..6 {
let a = (i as f32) * std::f32::consts::TAU / 6.0;
let (sn, cs) = a.sin_cos();
painter.line_segment(
[
egui::pos2(c.x + cs * r, c.y + sn * r),
egui::pos2(c.x + cs * h * 0.98, c.y + sn * h * 0.98),
],
stroke,
);
}
painter.circle_filled(c, r * 0.26, color);
}
// 分层(layers · 内核)
"layers" => {
for i in 0..3 {
let y = (i as f32 - 1.0) * 0.46;
painter.add(egui::Shape::convex_polygon(
vec![p(0.0, y - 0.34), p(0.86, y), p(0.0, y + 0.34), p(-0.86, y)],
if i == 1 { color } else { Color32::TRANSPARENT },
stroke,
));
}
}
// 书签缎带
"bookmark" => {
painter.add(egui::Shape::convex_polygon(
vec![
p(-0.56, -0.84),
p(0.56, -0.84),
p(0.56, 0.86),
p(0.0, 0.40),
p(-0.56, 0.86),
],
Color32::TRANSPARENT,
stroke,
));
}
// 柱状图(chart · grafana)
"chart" => {
line((-0.78, 0.76), (0.78, 0.76));
for (i, ht) in [0.60_f32, 1.00, 0.42].iter().enumerate() {
let x = c.x + h * (-0.52 + (i as f32) * 0.52);
painter.rect_filled(
Rect::from_min_max(
egui::pos2(x - h * 0.15, c.y + h * 0.76 - h * ht),
egui::pos2(x + h * 0.15, c.y + h * 0.72),
),
1.0,
color,
);
}
}
// ---------------------------------------------------------- Environment
// 终端提示符(harness / DSH-Starter)
"harness" => {
line((-0.62, -0.42), (-0.12, 0.0));
line((-0.12, 0.0), (-0.62, 0.42));
line((0.12, 0.45), (0.66, 0.45));
}
// ---------------------------------------------------------- VPN
// 拓扑:三个节点 + 连线
"topo" => {
let nodes = [p(-0.72, -0.42), p(0.76, -0.58), p(0.10, 0.70)];
for i in 0..3 {
for j in (i + 1)..3 {
painter.line_segment([nodes[i], nodes[j]], stroke);
}
}
for n in nodes.iter() {
painter.circle_filled(*n, h * 0.24, color);
}
}
// 链环(link · Channel)
"link" => {
painter.circle_stroke(p(-0.34, 0.0), h * 0.46, stroke);
painter.circle_stroke(p(0.34, 0.0), h * 0.46, stroke);
}
// ---------------------------------------------------------- Setting
// 圆 + 播放三角(启动动图)
"play-circle" => {
painter.circle_stroke(c, h * 0.86, stroke);
painter.add(egui::Shape::convex_polygon(
vec![p(-0.24, -0.38), p(0.40, 0.0), p(-0.24, 0.38)],
color,
Stroke::NONE,
));
}
// 打印机:机身 + 出纸口
"printer" => {
let body = Rect::from_center_size(c, egui::vec2(h * 1.5, h * 1.0));
painter.rect_stroke(body, 2.0, stroke, egui::StrokeKind::Inside);
let slot =
Rect::from_center_size(egui::pos2(c.x, body.top()), egui::vec2(h * 1.0, h * 0.42));
painter.rect_stroke(slot, 1.0, stroke, egui::StrokeKind::Inside);
}
// 文件夹
"folder" => {
let r = Rect::from_center_size(
egui::pos2(c.x, c.y + h * 0.10),
egui::vec2(h * 1.5, h * 1.0),
);
painter.add(egui::Shape::line(
vec![
egui::pos2(r.left(), r.top()),
egui::pos2(r.left() + h * 0.20, r.top() - h * 0.34),
egui::pos2(r.left() + h * 0.68, r.top() - h * 0.34),
egui::pos2(r.left() + h * 0.84, r.top()),
],
stroke,
));
painter.rect_stroke(r, 2.0, stroke, egui::StrokeKind::Inside);
}
// 圆柱(缓存)
"database" => {
let rx = h * 0.68;
let ry = h * 0.26;
painter.add(arc_ellipse(
c.x,
c.y - h * 0.52,
rx,
ry,
0.0,
std::f32::consts::PI,
stroke,
));
painter.add(arc_ellipse(
c.x,
c.y + h * 0.52,
rx,
ry,
std::f32::consts::PI,
std::f32::consts::TAU,
stroke,
));
painter.add(arc_ellipse(
c.x,
c.y - h * 0.52,
rx,
ry,
std::f32::consts::PI,
std::f32::consts::TAU,
stroke,
));
for dx in [-rx, rx] {
painter.line_segment(
[
egui::pos2(c.x + dx, c.y - h * 0.52),
egui::pos2(c.x + dx, c.y + h * 0.52),
],
stroke,
);
}
}
// 磁盘(存储)
"disk" => {
painter.circle_stroke(c, h * 0.84, stroke);
painter.circle_stroke(c, h * 0.28, stroke);
line((0.60, 0.60), (0.88, 0.88));
}
// 循环箭头(同步)
"sync" => {
painter.add(arc(c, h * 0.68, 0.35, 2.55, stroke));
painter.add(arc(
c,
h * 0.68,
std::f32::consts::PI + 0.35,
std::f32::consts::PI + 2.55,
stroke,
));
painter.add(egui::Shape::convex_polygon(
vec![p(0.24, -0.66), p(0.78, -0.74), p(0.58, -0.22)],
color,
Stroke::NONE,
));
painter.add(egui::Shape::convex_polygon(
vec![p(-0.24, 0.66), p(-0.78, 0.74), p(-0.58, 0.22)],
color,
Stroke::NONE,
));
}
// ---------------------------------------------------------- 其它
// 太阳
"weather" => {
let r = h * 0.42;
painter.circle_stroke(c, r, stroke);
for i in 0..8 {
let a = (i as f32) * std::f32::consts::TAU / 8.0;
let (sn, cs) = a.sin_cos();
painter.line_segment(
[
egui::pos2(c.x + cs * r * 1.45, c.y + sn * r * 1.45),
egui::pos2(c.x + cs * h * 0.98, c.y + sn * h * 0.98),
],
stroke,
);
}
}
// 拼图块(插件)
"puzzle" => {
let r = Rect::from_center_size(c, egui::vec2(h * 1.32, h * 1.32));
painter.rect_stroke(r, 2.0, stroke, egui::StrokeKind::Inside);
painter.circle_filled(egui::pos2(c.x, r.top()), h * 0.22, color);
painter.circle_filled(egui::pos2(r.right(), c.y), h * 0.22, color);
}
// ---------------------------------------------------------- Slack off
// 鱼(摸鱼)—— 身体椭圆 + 尾鳍 + 眼睛
"fish" => {
painter.add(egui::Shape::convex_polygon(
vec![
p(-0.86, 0.0),
p(-0.10, -0.52),
p(0.52, -0.34),
p(0.86, 0.0),
p(0.52, 0.34),
p(-0.10, 0.52),
],
Color32::TRANSPARENT,
stroke,
));
// 尾鳍
painter.add(egui::Shape::convex_polygon(
vec![p(-0.86, 0.0), p(-1.02, -0.52), p(-1.02, 0.52)],
Color32::TRANSPARENT,
stroke,
));
painter.circle_filled(p(0.52, -0.12), h * 0.10, color);
}
// 数字雨:三条竖虚线 + 一个亮点(下落的字符)
"rain" => {
for (i, x) in [-0.62_f32, 0.0, 0.62].iter().enumerate() {
let top = -0.88 + (i as f32) * 0.18;
for k in 0..4 {
let y = top + (k as f32) * 0.46;
if y > 0.86 {
break;
}
painter.rect_filled(
Rect::from_center_size(
egui::pos2(c.x + h * x, c.y + h * y),
egui::vec2(h * 0.22, h * 0.24),
),
1.0,
rain_fade(color, k),
);
}
}
}
// 鲨鱼鳍(Wireshark = 鲨鱼):三角鳍 + 一道水波
"shark" => {
painter.add(egui::Shape::convex_polygon(
vec![p(-0.16, -0.72), p(0.44, 0.20), p(-0.62, 0.20)],
Color32::TRANSPARENT,
stroke,
));
painter.add(egui::Shape::line(
vec![
egui::pos2(c.x - h * 0.98, c.y + h * 0.62),
egui::pos2(c.x - h * 0.36, c.y + h * 0.42),
egui::pos2(c.x + h * 0.28, c.y + h * 0.66),
egui::pos2(c.x + h * 0.98, c.y + h * 0.44),
],
stroke,
));
}
// 终端窗口:方框 + 提示符 + 光标
"terminal" => {
let r = Rect::from_center_size(c, egui::vec2(h * 1.62, h * 1.34));
painter.rect_stroke(r, 2.0, stroke, egui::StrokeKind::Inside);
// 标题栏分隔线
painter.line_segment(
[
egui::pos2(r.left(), c.y - h * 0.44),
egui::pos2(r.right(), c.y - h * 0.44),
],
stroke,
);
// >_
painter.add(egui::Shape::line(
vec![p(-0.54, -0.06), p(-0.24, 0.22), p(-0.54, 0.50)],
stroke,
));
painter.line_segment([p(0.06, 0.52), p(0.52, 0.52)], stroke);
}
// 双向传输箭头(WinSCP)
"transfer" => {
// 上:向右
painter.add(egui::Shape::line(
vec![p(-0.86, -0.34), p(0.62, -0.34)],
stroke,
));
painter.add(egui::Shape::convex_polygon(
vec![p(0.94, -0.34), p(0.42, -0.66), p(0.42, -0.02)],
color,
Stroke::NONE,
));
// 下:向左
painter.add(egui::Shape::line(
vec![p(0.86, 0.42), p(-0.62, 0.42)],
stroke,
));
painter.add(egui::Shape::convex_polygon(
vec![p(-0.94, 0.42), p(-0.42, 0.10), p(-0.42, 0.74)],
color,
Stroke::NONE,
));
}
// 兜底:一个圆点(宁可难看也不要空白)。
//
// 但这里必须**断死**:图标名是 `pages.rs` 与各调用点之间的契约,而调用点
// (最典型的是 `plugins/core_pages.rs` 里按下载状态硬编码的 "disk" /
// "database")根本不经过 `PAGES` —— 只靠那两个遍历 `PAGES` / `Group::ALL`
// 的测试罩不住,名字写错了三处测试全绿,界面上只是多一个小圆点。
// 宁可当场断在调用点。
//
// 用 `debug_assert!` 而不是 `assert!`:release 里名字写错的代价只是难看,
// 不值得为此让发布版崩掉;但它**不是**「只给测试看」的断言 —— 任何开
// debug 断言的运行都会校验。
_ => {
debug_assert!(is_known(kind), "未登记的图标名 `{kind}`(见 icons::NAMES)");
painter.circle_filled(c, h * 0.34, color);
}
}
}
/// 数字雨里第 `k` 个方块的颜色:**逐行淡出,并且把调用者颜色的 alpha 一起算进去**。
///
/// 为什么要有这个函数:以前这段是内联的,只取了 `color.r()/g()/b()`,alpha 直接写死
/// `255 * fade` —— 调用者传半透明色时,雨点反而比周围更不透明,整个「随主题一起变淡」
/// 的意图当场失效。今天所有调用点传的都是 `theme::*` 里的不透明常量
/// (`Color32::from_rgb`,alpha = 255),所以看不出差别,是颗潜伏的雷。
///
/// 为什么用 `gamma_multiply` 而不是「拆开 r/g/b 再拼回去」:`Color32` 内部是
/// **预乘 alpha** 的,`color.r()` 拿到的是已经乘过 alpha 的分量;再交给
/// `from_rgba_unmultiplied` 会**二次预乘** —— 半透明色每过一行就黑一档
/// (实测 `from_rgba_unmultiplied(200, 100, 50, 128)` 的 r 是 100,拼回去只剩 50)。
/// `gamma_multiply` 把四个分量一起等比缩小,在预乘空间里这正好就是
/// 「颜色不变、alpha 乘 fade」,正是淡出想要的效果。
///
/// `k` 是雨点的行号,调用点只传 `0..4`,`fade` 落在 `1.0 ~ 0.46`;
/// 传更大的 `k` 会让因子变负,`gamma_multiply` 自己的 `debug_assert` 会当场报出来。
fn rain_fade(color: Color32, k: usize) -> Color32 {
color.gamma_multiply(1.0 - (k as f32) * 0.18)
}
/// 把一个「外顶点 / 内顶点交替」的四角星拆成 8 个以星心为公共顶点的**凸**三角形。
///
/// 为什么要拆:星形的内外半径交替(`r` 与 `0.26 * r`),轮廓是**凹**的,而
/// `Shape::convex_polygon` 的填充按 epaint 0.32 的 `PathShape::fill` 文档只支持
/// **凸**多边形。整颗星喂进去,填充会把凹陷处也连起来、三角片互相重叠,今天的 7 个
/// 调用点全是不透明色所以看不出来 —— 换成半透明色就会看到深浅不匀的叠色块。
/// 拆成三角形后每一片都凸,而且各片以星心为公共顶点、张角互不重叠,拼起来正好是
/// 那颗星(单元测试用「三角形面积和 == 星形轮廓 shoelace 面积」钉住这一点)。
fn star_triangles(c: Pos2, h: f32, cx: f32, cy: f32, r: f32) -> Vec<[Pos2; 3]> {
let p = |dx: f32, dy: f32| egui::pos2(c.x + h * dx, c.y + h * dy);
let center = p(cx, cy);
let ring = [
p(cx, cy - r),
p(cx + r * 0.26, cy - r * 0.26),
p(cx + r, cy),
p(cx + r * 0.26, cy + r * 0.26),
p(cx, cy + r),
p(cx - r * 0.26, cy + r * 0.26),
p(cx - r, cy),
p(cx - r * 0.26, cy - r * 0.26),
];
(0..ring.len())
.map(|i| [center, ring[i], ring[(i + 1) % ring.len()]])
.collect()
}
/// 圆弧采样段数。
///
/// 16 段是实测够用的折中:本文件里跨度最大的弧是 `sync` 的 2.2 rad,每段约 7.9°,
/// 半径不超过 `h * 0.8`,弦高误差 < 0.002 * h;而全部调用点的方框只有 16~28 px,
/// 换算下来误差不到 0.02 px,抗锯齿之后根本看不出来。再往上加只是白烧顶点。
const ARC_SEGS: usize = 16;
/// 一段弧上的采样点(含两端,共 [`ARC_SEGS`] + 1 个点)。
///
/// 正圆弧与椭圆弧共用这一份采样:**不这么做**的话就得把这两份逐行复制
/// (`arc` / `arc_ellipse` 原来就是复制的),改一处忘一处,两条弧的疏密就会不一致。
fn arc_points(cx: f32, cy: f32, rx: f32, ry: f32, a0: f32, a1: f32) -> Vec<Pos2> {
(0..=ARC_SEGS)
.map(|i| {
let a = a0 + (a1 - a0) * (i as f32) / (ARC_SEGS as f32);
egui::pos2(cx + rx * a.cos(), cy + ry * a.sin())
})
.collect()
}
/// 一段正圆弧(采样成折线)。正圆就是 `rx == ry` 的椭圆,直接转调 [`arc_ellipse`]。
fn arc(c: Pos2, r: f32, a0: f32, a1: f32, stroke: Stroke) -> egui::Shape {
arc_ellipse(c.x, c.y, r, r, a0, a1, stroke)
}
/// 椭圆弧 —— 圆柱(数据库)那种「上半圈 / 下半圈」用得上。
fn arc_ellipse(cx: f32, cy: f32, rx: f32, ry: f32, a0: f32, a1: f32, stroke: Stroke) -> egui::Shape {
egui::Shape::line(arc_points(cx, cy, rx, ry, a0, a1), stroke)
}
#[cfg(test)]
mod tests {
use super::*;
/// 图标名必须是已知的 —— 拼错只会静默退化成一个小圆点。
#[test]
fn every_page_icon_is_known() {
for info in crate::pages::PAGES {
assert!(
is_known(info.icon),
"页面 `{}` 用了未登记的图标名 `{}`",
info.id,
info.icon
);
}
}
/// 分组图标同样要登记。
#[test]
fn every_group_icon_is_known() {
for g in crate::pages::Group::ALL {
assert!(is_known(g.icon()), "分组 {g:?} 的图标名没登记");
}
}
#[test]
fn names_are_unique() {
let mut seen = std::collections::HashSet::new();
for n in NAMES {
assert!(seen.insert(*n), "图标名重复:{n}");
}
}
/// `draw` 的兜底分支必须断死:名字是调用点与 `NAMES` 之间的契约,
/// 而调用点里存在**不经过 `PAGES`** 的硬编码名字(见 `plugins/core_pages.rs`
/// 里按下载状态取的 "disk" / "database"),光靠上面那两个遍历 `PAGES` /
/// `Group::ALL` 的测试罩不住。
///
/// 没有这条断言时,把名字拼错只会静默画成一个小圆点,三处测试全绿。
/// 用的是 `debug_assert!`,所以只在开 debug 断言的构建里成立 —— 测试同样如此。
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "未登记的图标名")]
fn draw_panics_on_unregistered_icon_name() {
let ctx = egui::Context::default();
let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(16.0, 16.0));
// 造 painter 不会用到 `ctx` 的帧状态(`Painter::new` 只读 pixels_per_point),
// 而断言的 panic 发生在第一个绘制调用之前,所以这里不需要真的开一帧。
let painter = egui::Painter::new(ctx, egui::LayerId::debug(), rect);
// "sparkel" 是 "sparkle" 的典型拼错 —— 走 `_ =>` 兜底分支。
draw(&painter, rect, "sparkel", Color32::WHITE);
}
/// 数字雨必须把调用者颜色的 alpha 一起算进去。
///
/// 复发形态一:把 `rain_fade` 改回内联的
/// `from_rgba_unmultiplied(color.r(), color.g(), color.b(), 255 * fade)`
/// —— 半透明色的雨点会比周围更实,而所有不透明色的用例都还是绿的。
/// 复发形态二:改成 `from_rgba_unmultiplied(r, g, b, ...)` 这种「拆开再拼回去」
/// 的写法 —— `Color32` 是预乘的,拼回去等于二次预乘,颜色会一档比一档黑。
#[test]
fn rain_fade_multiplies_the_callers_alpha_instead_of_forcing_it() {
let half = Color32::from_rgba_unmultiplied(200, 100, 50, 128);
assert_eq!(half.a(), 128, "前提:这是个半透明色");
// 第一行不淡出:颜色**原样**返回(以前这里会用 255 当 alpha,
// 把雨点画得比周围更实)。
assert_eq!(rain_fade(half, 0), half);
// 往下逐行变淡,alpha 严格下降,且永远不超过调用者给的 alpha。
let mut prev = half.a();
for k in 1..4 {
let c = rain_fade(half, k);
assert!(c.a() < prev, "第 {k} 行没有变淡:{} !< {prev}", c.a());
assert!(
c.a() <= half.a(),
"第 {k} 行的 alpha {} 超过了调用者给的 {}",
c.a(),
half.a()
);
// 预乘空间的四个分量必须等比缩小,不能只动 alpha。
assert!(c.r() <= half.r(), "第 {k} 行的 r 没有跟着缩");
prev = c.a();
}
// 不透明色:仍是原色,数值与改动前逐字节一致(255 * 0.82 → 209)。
let opaque = Color32::from_rgb(1, 2, 3);
assert_eq!(rain_fade(opaque, 0), opaque);
assert_eq!(rain_fade(opaque, 1).a(), 209);
}
#[test]
fn sparkle_is_split_into_convex_tiles_that_cover_the_star() {
let c = egui::pos2(3.0, -7.0);
let h = 8.0;
let (cx, cy, r) = (-0.22, -0.16, 0.72);
let tris = star_triangles(c, h, cx, cy, r);
assert_eq!(tris.len(), 8, "四角星(4 个外顶点 + 4 个内顶点)应拆成 8 片");
// 每片都以星心为公共顶点,且不能退化成零面积 ——
// 退化片在 `convex_polygon` 下什么都画不出来,等于把星形挖掉一块。
let center = tris[0][0];
let mut fan_area = 0.0_f32;
for t in &tris {
assert_eq!(t[0], center, "三角片必须以星心为公共顶点:{t:?}");
let a = tri_area(t[0], t[1], t[2]);
assert!(a > 1e-6, "三角片退化:{t:?}");
fan_area += a;
}
// 相邻两片必须首尾相接(第 i 片的末点 == 第 i+1 片的起点)。
let ring: Vec<Pos2> = tris.iter().map(|t| t[1]).collect();
for (i, t) in tris.iter().enumerate() {
assert_eq!(t[2], ring[(i + 1) % ring.len()], "第 {i} 片与下一片没有接上");
}
// 三角片面积之和 == 星形轮廓的 shoelace 面积:
// 多了说明片与片重叠(把凹多边形直接交给 `convex_polygon` 就是这个症状),
// 少了说明漏了一块。
let outline_area = polygon_area(&ring).abs();
assert!(
(fan_area - outline_area).abs() < 1e-3 * outline_area,
"三角片没有正好铺满星形:片面积和 {fan_area},轮廓面积 {outline_area}"
);
// 轮廓必须是**凹**的(凹陷正是不能整颗交给 `convex_polygon` 的原因)。
// 哪天有人把内半径放大到与外半径相等,形状就不再是星形,这条会红。
assert!(is_concave(&ring), "四角星轮廓变成了凸多边形");
}
/// 正圆弧与椭圆弧共用一份采样:段数由 [`ARC_SEGS`] 钉死,端点按角度算准。
///
/// 复发形态:有人把 `arc` 重新内联成另一份自己的采样(改动前就是两份逐行复制的
/// 实现),两条弧的疏密从此可以各自漂移。
#[test]
fn circle_arc_shares_the_ellipse_sampler() {
let stroke = Stroke::new(1.5_f32, Color32::WHITE);
let (a0, a1) = (0.35_f32, 2.55_f32);
// 正圆弧 == rx、ry 都等于 r 的椭圆弧,逐点相同(Shape 派生 PartialEq)。
assert_eq!(
arc(egui::pos2(10.0, 20.0), 5.0, a0, a1, stroke),
arc_ellipse(10.0, 20.0, 5.0, 5.0, a0, a1, stroke)
);
let pts = arc_points(10.0, 20.0, 4.0, 2.0, 0.0, std::f32::consts::FRAC_PI_2);
assert_eq!(pts.len(), ARC_SEGS + 1, "采样点数应含两端");
assert!(
(pts[0] - egui::pos2(14.0, 20.0)).length() < 1e-4,
"起点错了:{:?}",
pts[0]
);
// 90° 处:y 方向用 ry,角度换算不能把 rx / ry 弄反。
assert!(
(pts[ARC_SEGS] - egui::pos2(10.0, 22.0)).length() < 1e-4,
"终点错了:{:?}",
pts[ARC_SEGS]
);
}
/// 三角形面积(绝对值)。
fn tri_area(a: Pos2, b: Pos2, c: Pos2) -> f32 {
((b - a).x * (c - a).y - (c - a).x * (b - a).y).abs() * 0.5
}
/// 多边形有向面积(shoelace)。符号表示绕向,调用处自己取 `abs`。
fn polygon_area(pts: &[Pos2]) -> f32 {
let n = pts.len();
let mut s = 0.0;
for i in 0..n {
let a = pts[i];
let b = pts[(i + 1) % n];
s += a.x * b.y - b.x * a.y;
}
s * 0.5
}
/// 轮廓上是否既有左转顶点也有右转顶点(有 = 凹)。
fn is_concave(pts: &[Pos2]) -> bool {
let n = pts.len();
let (mut left, mut right) = (false, false);
for i in 0..n {
let a = pts[i];
let b = pts[(i + 1) % n];
let c = pts[(i + 2) % n];
let cross = (b - a).x * (c - b).y - (b - a).y * (c - b).x;
left |= cross > 1e-9;
right |= cross < -1e-9;
}
left && right
}
}