跳转至

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
    }
}