crates\nfarms-gui\src\theme.rs¶
源码路径:
crates\nfarms-gui\src\theme.rs· 354 行
//! 配色与自绘工具。对应 C# `MainForm.cs` 里的 `static class Ui`。
//!
//! 原版的视觉是「雾玻璃 + 圆角 + 品牌绿」,这里用 egui 的 painter 复刻同一套语言。
//! 差别在于:WinForms 那边得跟 `OnPaintBackground`、双缓冲、圆角黑点斗智斗勇,
//! egui 是立即模式,每帧重画,`rect_filled` 带圆角就是真的圆角,没有那些坑。
use eframe::egui::{self, Color32, Pos2, Rect, Stroke};
// ---- 品牌色(直接抄 C# 版 Ui 里的常量)----
pub const ACCENT: Color32 = Color32::from_rgb(62, 165, 107); // NFarms 绿
pub const ACCENT_DARK: Color32 = Color32::from_rgb(46, 128, 82);
pub const BORDER: Color32 = Color32::from_rgb(226, 232, 228);
pub const TEXT_MAIN: Color32 = Color32::from_rgb(34, 42, 38);
pub const TEXT_SUB: Color32 = Color32::from_rgb(110, 122, 114);
pub const DANGER: Color32 = Color32::from_rgb(208, 86, 107);
/// 侧栏文字:底色是浅色雾玻璃,所以文字要深(原版踩过「深绿板上配深字」的坑)
pub const NAV_TEXT_IDLE: Color32 = Color32::from_rgb(64, 80, 71);
pub const NAV_TEXT_HOVER: Color32 = Color32::from_rgb(28, 56, 42);
/// 整窗背景底色。原版支持自定义背景照片,这里先用纯色 ——
/// 纯色还有个附带好处:自绘圆角时可以用它精确补角(见 `gradient_card`)。
pub const APP_BG: Color32 = Color32::from_rgb(233, 239, 235);
/// 天气主页那张「紫 → 洋红」渐变卡片(原版签名视觉)。
pub const HERO_TOP: Color32 = Color32::from_rgb(52, 20, 92);
pub const HERO_BOTTOM: Color32 = Color32::from_rgb(158, 28, 96);
/// 半透明白雾玻璃。原版各处的 alpha 值都在这里对齐。
///
/// 用 `from_rgba_premultiplied` 而不是 `..._unmultiplied`:只有前者是 `const fn`,
/// 这样各个玻璃色才能写成编译期常量。白色 + alpha 的预乘形式就是 `(a,a,a,a)`。
pub const fn glass(alpha: u8) -> Color32 {
Color32::from_rgba_premultiplied(alpha, alpha, alpha, alpha)
}
// 下面每个常量上面那句写的是**它在界面上的作用**和与相邻层的相对关系。
// 数值本身是照抄 C# 原版的,不是这里推出来的;想调色就照那句作用调,别只看数字大小
// —— 层级一倒挂(比如悬停比选中还实),用起来立刻别扭。顺序由测试钉住。
/// 主卡片底:alpha 212/255 ≈ 0.83,页面上最实的一层,卡片里的正文要能直接读。
pub const GLASS_CARD: Color32 = glass(212);
/// 侧栏底:alpha 150/255 ≈ 0.59,比卡片薄,好让背景(照片或底色)透出来 ——
/// 「雾玻璃」观感主要来源;再薄侧栏文字就开始跟背景打架。
pub const GLASS_SIDEBAR: Color32 = glass(150);
/// 选中的导航项:alpha 238/255 ≈ 0.93,最实,一眼看出「现在在哪一页」。
pub const GLASS_NAV_ACTIVE: Color32 = glass(238);
/// 悬停的导航项:alpha 186/255 ≈ 0.73,夹在侧栏底(150)与选中(238)之间 ——
/// 要看得出鼠标在上面,又不能实到被误认成选中。
pub const GLASS_NAV_HOVER: Color32 = glass(186);
/// 输入框底:alpha 236/255 ≈ 0.93,比它下面的卡片(212)更实,
/// 否则卡片底下的内容会透上来,输入的文字糊在一起。
pub const GLASS_INPUT: Color32 = glass(236);
/// 圆角半径(原版自绘控件里到处都是,统一在这里)
pub const RADIUS_CARD: f32 = 14.0;
pub const RADIUS_CTRL: f32 = 8.0;
/// 卡片样式:雾玻璃底 + 一圈浅描边 + 圆角。
pub fn card_frame() -> egui::Frame {
egui::Frame::NONE
.fill(GLASS_CARD)
.stroke(Stroke::new(1.0_f32, BORDER))
.corner_radius(RADIUS_CARD)
.inner_margin(16)
}
/// 画一张「上下两色」的渐变圆角卡片。
///
/// egui 没有「带圆角的渐变」这种现成形状:能渐变的是 `Mesh`,能圆角的是
/// `rect_filled`,两者不通用。做法是先用 Mesh 铺满整块矩形,再用四个**角补丁**
/// 把圆角外的那一点点用背景色盖掉 —— 背景是纯色时,补出来是精确的。
///
/// (原版在 WinForms 里为「圆角外未初始化像素 → 黑点」折腾了很久;这里换了个
/// 思路:不裁,而是把角补回去。)
///
/// 补丁的颜色是**每个顶点各取一次**背景色(`patch` 会被逐点调用,顶点色由 egui
/// 线性插值),不是「一个角一个色」。理由是照片底:那一小块(半径 14 时约 40px²)
/// 若整块用外角单点的颜色,和旁边的背景对不上,角上能看出色差。
/// 纯色底下所有顶点取到同一个值,退化成原来的一次取色,观感不变。
pub fn gradient_card(
painter: &egui::Painter,
rect: Rect,
radius: f32,
top: Color32,
bottom: Color32,
patch: impl Fn(egui::Pos2) -> Color32,
) {
// 1) 渐变本体
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(rect.left_top(), top);
mesh.colored_vertex(rect.right_top(), top);
mesh.colored_vertex(rect.right_bottom(), bottom);
mesh.colored_vertex(rect.left_bottom(), bottom);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(mesh));
// 2) 四个角补回背景色。背景是纯色时就是那个颜色;背景是照片时,
// 调用方按同一个 cover 映射从照片上取色(见 Background::sample),
// 并且是逐顶点取色(见 corner_patch_mesh)。
for corner in 0..4 {
corner_patch(painter, rect, radius, corner, &patch);
}
// 3) 一圈很淡的描边,让卡片在浅色背景上立起来。
// alpha 40/255 ≈ 0.16:只是勾一道边,再亮会把雾玻璃的透亮感压掉,
// 浅色底上尤其显脏(原版也是这个量级)。
painter.rect_stroke(
rect,
radius,
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 40)),
egui::StrokeKind::Inside,
);
}
/// 弧的弦高上限(px):弧上每段弦与弧之间的最大距离。
/// 0.1px 在 1x 屏上肉眼不可见,2x 屏上也只有 0.2 物理像素。
const ARC_MAX_SAGITTA: f32 = 0.1;
/// 分段数的上下限:下限免得小半径也画一堆点,上限免得极大半径把网格撑爆。
const ARC_MIN_SEGMENTS: usize = 2;
const ARC_MAX_SEGMENTS: usize = 64;
/// 给定半径下,90° 的圆角弧要分成几段,弦高才不超过 `ARC_MAX_SAGITTA`。
///
/// 90° 的弧分成 n 段,每段张角 π/(2n)、半段角 φ = π/(4n),弦高 = r(1 - cos φ)。
/// 要求 ≤ tol 即 φ ≤ acos(1 - tol/r),于是 n ≥ π / (4·acos(1 - tol/r))。
/// r = `RADIUS_CARD`(14)时算出 7 段,弦高 0.088px。
///
/// 为什么按半径取档,而不是写死一个 8:半径是 `gradient_card` 的入参,
/// 写死的话小半径白画点,而半径一大(有人拿它画更大的圆角)就会露出
/// 肉眼可见的多边形棱角。上下限只是保护:r ≤ 800 都落在区间内,再大才封顶。
fn arc_segments(r: f32) -> usize {
let phi = (1.0 - ARC_MAX_SAGITTA / r).clamp(-1.0, 1.0).acos();
let n = (std::f32::consts::FRAC_PI_2 / (2.0 * phi)).ceil();
(n as usize).clamp(ARC_MIN_SEGMENTS, ARC_MAX_SEGMENTS)
}
/// 造出某个圆角外那块补丁的三角扇。位置与颜色都在返回值里,测试直接断言它。
///
/// 顶点 0 是外角,1..=n 是弧上的点(含弧的两端),n = `arc_segments(r)`。
fn corner_patch_mesh(
rect: Rect,
r: f32,
corner: usize,
color_at: &impl Fn(Pos2) -> Color32,
) -> egui::Mesh {
// 圆心(向内偏移 r)与外角
let (center, outer) = match corner {
0 => (rect.left_top() + egui::vec2(r, r), rect.left_top()),
1 => (rect.right_top() + egui::vec2(-r, r), rect.right_top()),
2 => (
rect.right_bottom() + egui::vec2(-r, -r),
rect.right_bottom(),
),
_ => (rect.left_bottom() + egui::vec2(r, -r), rect.left_bottom()),
};
// 弧落在哪个象限,由「圆心 → 外角」的方向推出来(**屏幕坐标 y 向下**,
// 角度按 atan2(dy, dx) 量、顺时针为正),不写死 180..270 那种度数表:
// 写死的话,一旦 y 轴约定或 outer/center 的取法变了,补丁会静静地补到
// 别的角上 —— 画面上只是某两个角各多/各缺一块,很难一眼看出来。
// 对角方向的 atan2 必是 ±45°/±135°,不会落在 90° 的边界上,floor 取象限是稳的。
let dir = outer - center;
let base = (dir.y.atan2(dir.x) / std::f32::consts::FRAC_PI_2).floor()
* std::f32::consts::FRAC_PI_2;
let n = arc_segments(r);
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(outer, color_at(outer));
for i in 0..=n {
let a = base + std::f32::consts::FRAC_PI_2 * (i as f32 / n as f32);
let pos = egui::pos2(center.x + r * a.cos(), center.y + r * a.sin());
// 逐顶点取色:整块共用外角那一个像素的颜色的做法,在纯色底上没问题,
// 但照片底上这一小块会和旁边的背景对不上(见 gradient_card 的文档)。
mesh.colored_vertex(pos, color_at(pos));
}
for i in 0..n as u32 {
// 三角形顶点顺序影响的是背面剔除,egui 不做剔除,顺逆时针都行。
mesh.add_triangle(0, 1 + i, 2 + i);
}
mesh
}
/// 把某个圆角外的那一小块用背景色盖掉。
///
/// 补丁是「外角 + 一段圆弧」围成的区域。它对自己那个外角是**星形**的,
/// 所以可以用三角扇精确铺满 —— 不能用 `convex_polygon`,那个区域不是凸的。
///
/// 前提:**屏幕坐标,y 轴向下的角度约定**(egui 的坐标就是这么定的),
/// 弧的象限因此从几何推出来而不是查表,见 `corner_patch_mesh`。
///
/// `color_at` 按**屏幕坐标**取背景色:纯色底直接返回常量,照片底则去采样。
/// 每个弧上顶点都会调一次,所以它必须够便宜(`Background::sample` 是
/// 缩略图上的数组查表,一帧几十次可以忽略)。
fn corner_patch(
painter: &egui::Painter,
rect: Rect,
r: f32,
corner: usize,
color_at: &impl Fn(Pos2) -> Color32,
) {
if r <= 0.0 {
return;
}
painter.add(egui::Shape::mesh(corner_patch_mesh(
rect, r, corner, color_at,
)));
}
/// 画一段圆角进度/强调条。
pub fn accent_bar(painter: &egui::Painter, rect: Rect, color: Color32) {
painter.rect_filled(rect, rect.width() / 2.0, color);
}
#[cfg(test)]
mod tests {
use super::*;
/// 一张和天气页主卡片差不多大的矩形,用来算补丁几何。
fn card() -> Rect {
Rect::from_min_size(Pos2::new(100.0, 50.0), egui::vec2(300.0, 156.0))
}
/// 第 `corner` 个补丁对应的**矩形角**(0=左上 1=右上 2=右下 3=左下)。
fn rect_corner(rect: Rect, corner: usize) -> Pos2 {
match corner {
0 => rect.left_top(),
1 => rect.right_top(),
2 => rect.right_bottom(),
_ => rect.left_bottom(),
}
}
/// 四个角补丁必须各自落在**自己那个角**上。
///
/// 这是「角度表写死 y 向下」那条的复发测试:象限取错时,弧上的点会跑到圆心
/// 另一侧(离该矩形角的距离从 ≤ r 变成 ≥ √2·r),补丁就补到了别的角上。
#[test]
fn each_patch_lands_on_its_own_corner() {
let rect = card();
let r = RADIUS_CARD;
let n = arc_segments(r);
for corner in 0..4 {
let mesh = corner_patch_mesh(rect, r, corner, &|_: Pos2| APP_BG);
let k = rect_corner(rect, corner);
// 1 个外角 + (n+1) 个弧上点
assert_eq!(mesh.vertices.len(), n + 2, "corner {corner} 的顶点数");
assert_eq!(
mesh.vertices[0].pos, k,
"corner {corner} 的顶点 0 必须是那个外角"
);
for v in &mesh.vertices[1..] {
let d = (v.pos - k).length();
// 弧上点离矩形角最远是 r(弧两端)、最近是 (√2-1)r ≈ 0.414r(弧中点)。
assert!(
(0.4 * r..=1.05 * r).contains(&d),
"corner {corner}:弧上点 {:?} 离该角 {d},不在 [0.4r, 1.05r] 内(象限错了?)",
v.pos
);
}
// 弧的两端必须落在矩形边上,圆角才是贴着边收进去的
for end in [mesh.vertices[1].pos, mesh.vertices[n + 1].pos] {
let on_edge = (end.x - rect.left()).abs() < 0.01
|| (end.x - rect.right()).abs() < 0.01
|| (end.y - rect.top()).abs() < 0.01
|| (end.y - rect.bottom()).abs() < 0.01;
assert!(on_edge, "corner {corner}:弧端点 {end:?} 没落在矩形边上");
}
}
}
/// 补丁的颜色必须逐顶点取自 `color_at`。
///
/// 复发场景:退回「每个角只在外角取一次色、整块填平色」——照片背景下四个角
/// 会露出与周围背景对不上的色块(`Background::sample` 的存在意义就是这个)。
#[test]
fn patch_colors_come_from_every_vertex() {
let rect = card();
let sampler = |p: Pos2| Color32::from_rgb((p.x % 251.0) as u8, (p.y % 241.0) as u8, 7);
let mesh = corner_patch_mesh(rect, RADIUS_CARD, 0, &sampler);
for v in &mesh.vertices {
assert_eq!(
v.color,
sampler(v.pos),
"顶点 {:?} 的颜色不是按该点采样来的",
v.pos
);
}
// 弧两端在矩形上是两个位置,采样结果必须真的不同 ——
// 否则上面的断言在「全取外角色」的实现下也会碰巧通过。
let first = mesh.vertices[1].pos;
let last = mesh.vertices[mesh.vertices.len() - 1].pos;
assert_ne!(sampler(first), sampler(last), "测试用的采样函数要能区分位置");
// 纯色底(最常见的路径)必须退化成同一个颜色,不多画色带
let flat = corner_patch_mesh(rect, RADIUS_CARD, 0, &|_: Pos2| APP_BG);
assert!(flat.vertices.iter().all(|v| v.color == APP_BG));
}
/// 分段数要真的把弦高压在容差内,并且随半径增大(不减少)。
///
/// 复发场景:把分段数写死成一个偏小的常量(原来是 8)——小半径没事,
/// 半径一大圆角就露出多边形的棱角。
#[test]
fn arc_segments_hold_chord_tolerance() {
let mut prev = 0;
for r in [1.0_f32, 4.0, 14.0, 40.0, 120.0, 400.0, 800.0] {
let n = arc_segments(r);
assert!((ARC_MIN_SEGMENTS..=ARC_MAX_SEGMENTS).contains(&n), "r={r} n={n}");
let sagitta = r * (1.0 - (std::f32::consts::PI / (4.0 * n as f32)).cos());
assert!(
sagitta <= ARC_MAX_SAGITTA + 1e-4,
"r={r} n={n}:弦高 {sagitta} 超过容差"
);
assert!(n >= prev, "半径变大时段数不该变少(r={r})");
prev = n;
}
}
/// `glass` 是「白色 + alpha」的预乘形式,必须是 `(a,a,a,a)`。
///
/// 复发场景:有人图省事改成 `from_rgba_unmultiplied`(它不是 `const fn`,
/// 这些常量就编译不过了),或者把参数顺序写错 —— 玻璃色会整体偏暗/偏色。
#[test]
fn glass_is_premultiplied_white() {
for a in [0u8, 40, 150, 186, 212, 236, 238, 255] {
let c = glass(a);
assert_eq!(
(c.r(), c.g(), c.b(), c.a()),
(a, a, a, a),
"glass({a}) 应当等于预乘白 (a,a,a,a)"
);
}
}
/// 玻璃色的不透明度层级要和各常量注释里写的**作用**一致。
///
/// 复发场景:调色时只改一个数字,把层级搞倒挂(比如悬停比选中还实、
/// 输入框比它下面的卡片还透)——视觉上很别扭,但不报任何错。
#[test]
fn glass_opacity_order_matches_its_roles() {
assert!(
GLASS_SIDEBAR.a() >= 128,
"侧栏底太透,深色背景上的侧栏文字会读不清"
);
assert!(GLASS_SIDEBAR.a() < GLASS_NAV_HOVER.a(), "悬停要比侧栏底实");
assert!(GLASS_NAV_HOVER.a() < GLASS_NAV_ACTIVE.a(), "选中要比悬停实");
assert!(GLASS_CARD.a() < GLASS_INPUT.a(), "输入框要比它下面的卡片实");
}
}