crates\nfarms-gui\src\background.rs¶
源码路径:
crates\nfarms-gui\src\background.rs· 851 行
//! 自定义背景图片。
//!
//! 对应 C# 版 `Settings` 页的「自定义软件背景图片」,以及 `MainForm.cs` 里的
//! `ApplyBackground` / `DownscaleForBackground` / `BackgroundPanel`。
//!
//! 原版的做法是「按客户区尺寸在后台线程预渲染一次,自绘背景面板按裁剪区 1:1 贴图」。
//! egui 是立即模式、每帧重画,不需要那套缓存 —— 但**降采样仍然必须做**:
//! 一张 6000×4000 的照片每帧缩放到窗口大小,帧率会掉得很难看。
//!
//! 另外这里多了一件事原版没有:**保留一张 32×32 缩略图用于取色**。
//! 原因见 [`Background::sample`]。
use std::path::{Path, PathBuf};
use std::sync::mpsc::Receiver;
use eframe::egui;
/// 背景图最大边长。超过就等比缩下来再上传纹理。
///
/// **2048 不是随便定的**:egui 的 `Context::load_texture` 里有一条
/// `debug_assert!(image.width() <= max_texture_side && ...)`
/// (egui-0.32.3 `context.rs:2207-2215`),超了直接 panic:
/// `Texture "..." has size 2560x1280, but the maximum texture side is 2048`。
/// 这是被 `huge_image_is_downscaled_before_upload` 那条测试抓出来的 ——
/// 我一开始写的 2560,拿一张 4000×2000 的图一试就炸。
///
/// **这个常量只是天花板,不是实际用的值**:那个 assert 比的
/// `max_texture_side` 来自 `egui::InputState`,后端不报时 egui 自己给 2048
/// (egui-0.32.3 的 `src/input_state/mod.rs:320`),小显存 / 软渲染下也可能报得更小。
/// 真正传给解码的是 `min(后端上报值, MAX_SIDE)` —— 见 [`texture_side_limit`]。
/// 只往下取不往上取:驱动报 16384 我们也只按 2048 走,再大只是白占显存,
/// 肉眼在雾玻璃底下看不出差别。
const MAX_SIDE: u32 = 2048;
/// 取色缩略图边长。
///
/// **正方形,而且是故意的**:不管原图多宽多高都压成 `THUMB×THUMB`
/// (见 [`decode`] 里那行 `imageops::resize`),宽高比是丢掉的。
/// 能这么干是因为 [`Background::sample`] 只按归一化坐标 `u/v ∈ [0,1]` 取色,
/// 而缩略图在 x/y 两轴上各自覆盖整幅图 —— 压扁不影响位置映射
/// (映射用的 cover 矩形是按**原图**尺寸 `source_size` 算的)。
/// 别改成"等比内接"(32×16 + 补边):补出来的边会被当成照片内容取到,
/// 天气页用背景色补圆角的那一圈就会取错色。测试
/// `wide_image_thumb_stays_square_on_purpose` 钉住了这个行为。
const THUMB: usize = 32;
/// 「解码失败」文案里列出的格式名。
///
/// **必须与 `crates/nfarms-gui/Cargo.toml` 里 `image` 的 features 同步**
/// (`png / jpeg / bmp / webp`)。原先这段是一段写死的字面量,和特性列表之间
/// 没有任何联动 —— 特性一变,文案就开始骗用户(说支持,其实解不开)。
/// 文案现在由这个常量拼出来(单一来源),再加
/// `decode_error_message_lists_manifest_features` 去读 manifest 对齐,
/// 加特性却忘了改这里会被测试抓住。
/// 注意命名有一处不一致:`jpeg` 特性在界面文案里写作 `jpg`,别以为是漏了。
const SUPPORTED_FORMATS: &str = "png / jpg / bmp / webp";
#[derive(Default)]
pub struct Background {
pub path: Option<PathBuf>,
pub error: Option<String>,
/// 原图尺寸(降采样前),界面上显示用
pub source_size: Option<(u32, u32)>,
texture: Option<egui::TextureHandle>,
/// 32×32 的 RGBA,用于采样某个屏幕位置对应照片上的颜色
thumb: Option<(usize, usize, Vec<u8>)>,
/// 后台解码中
rx: Option<Receiver<Result<Decoded, String>>>,
}
/// 后台线程解出来的东西。`ColorImage` 是 `Send`,所以可以跨线程搬。
/// 纹理**不**在后台线程上传 —— 那一步留给 UI 线程,免得纠结 `TextureHandle`
/// 能不能跨线程。
type Decoded = (egui::ColorImage, (usize, usize, Vec<u8>), (u32, u32));
/// 一次载入的结果,用来写日志 / 更新设置。
pub enum LoadOutcome {
Ok { width: u32, height: u32 },
Err(String),
}
/// 给图片阅读器用的解码入口:只要全图 + 原图尺寸,不要那张取色缩略图。
///
/// 复用同一个 `decode` 而不是在阅读器里再写一遍 —— 同一份降采样阈值、
/// 同一份「支持哪些格式」的错误文案,升级 `image` 时也只需要改一处。
///
/// 上限只能给保守的 [`MAX_SIDE`]:这个入口的调用点(`viewer.rs` 的
/// `Viewer::load_async`)手里没有 `egui::Context`,拿不到 `max_texture_side`,
/// 而 `decode` 必须在解码前就知道目标尺寸。不是"忘了接",是那个调用点
/// 传不进来;真要按运行时上限走,得先给 `decode_for_viewer` 加一个
/// `limit` 参数并改 `viewer.rs` / `core_pages.rs` 的调用点。
pub(crate) fn decode_for_viewer(path: &Path) -> Result<(egui::ColorImage, (u32, u32)), String> {
decode(path, MAX_SIDE).map(|(img, _thumb, src)| (img, src))
}
impl Background {
/// 按设置里的路径**后台**载入(启动时调一次)。
///
/// 原版 README 专门记过「背景预渲染移到后台线程」——这不是优化,是必需:
/// 一张 2560×1440 的 PNG 在 UI 线程上解码+缩放要好 几秒,窗口会白等。
/// (我第一版就是同步做的,`--page X` 的冒烟测试里 10 页有 8 页来不及写日志。)
pub fn from_setting(ctx: &egui::Context, path: &str) -> Self {
let mut bg = Self::default();
let path = path.trim();
if !path.is_empty() {
bg.load_async(ctx, Path::new(path));
}
bg
}
/// 背景图**是不是已经能画了** —— 判据是纹理已上传,不是"设置里有路径"。
///
/// 载入中(解码线程还在跑)会返回 `false`,于是设置页的预览走 else 分支画
/// "默认纯色"(`core_pages.rs:242-259`),天气页 `sample` 因为 `thumb`
/// 还没落也退回 `theme::APP_BG`(`background.rs` 的 `sample` 里第一行
/// `self.thumb.as_ref()?`);载完的同一帧两处一起跳成照片。
/// 这是有意的:宁可先给纯色,也别画半张图或者干脆空着。
/// 想区分"没设置"和"正在载入",用 [`Self::is_loading`]。
pub fn is_set(&self) -> bool {
self.texture.is_some()
}
pub fn is_loading(&self) -> bool {
self.rx.is_some()
}
/// 后台解码。
///
/// 已经在载入时**不拒绝**新请求:用户后选的那张必须赢(`self.rx` 被覆盖,
/// 先起跑那条线程的 `tx.send` 静默失败)。但覆盖这件事要留痕,否则第一次
/// 载入的下场在任何地方都看不见 —— 所以这里把提示写进 `self.error`,
/// 设置页会把它画出来(`core_pages.rs:277` 读的就是 `background.error`),
/// 新的一次载入成功时 `poll` 会把 `error` 清掉。
/// 那一行是 `ui::err_hint`(红字),所以文案写成"被取代"而不是"失败":
/// 这是提示,不是错误;换成别的措辞反而会让人以为这次载入挂了。
///
/// 反过来"载入中就直接 return"是不行的:`apply_background`
/// (`lib.rs:1382-1386`)在调本函数**之前**已经写了一条
/// "背景图载入中: <新路径>",拒绝新请求会变成日志说是新图、实际载入旧图。
pub fn load_async(&mut self, ctx: &egui::Context, path: &Path) {
let superseded = self.rx.is_some();
// 上限必须在 UI 线程上取好再进线程:`Context::load_texture` 的
// debug_assert 比的就是同一帧这个 `InputState::max_texture_side`。
let limit = texture_side_limit(ctx.input(|i| i.max_texture_side));
let path = path.to_path_buf();
self.path = Some(path.clone());
self.error = if superseded {
Some("上一次背景图载入还没跑完,已被这次取代(那次的结果不再上报)".to_string())
} else {
None
};
let (tx, rx) = std::sync::mpsc::channel();
self.rx = Some(rx);
let ctx = ctx.clone();
std::thread::spawn(move || {
let r = decode(&path, limit);
let _ = tx.send(r);
ctx.request_repaint(); // 算完叫一声,不然界面要等下一次输入才刷新
});
}
/// 每帧轮询。载入完成时返回结果,由调用方决定怎么记日志/落盘。
pub fn poll(&mut self, ctx: &egui::Context) -> Option<LoadOutcome> {
let rx = self.rx.take()?;
match rx.try_recv() {
Ok(Ok((image, thumb, src))) => {
self.source_size = Some(src);
self.thumb = Some(thumb);
self.texture =
Some(ctx.load_texture("nfarms-background", image, egui::TextureOptions::LINEAR));
self.error = None;
Some(LoadOutcome::Ok {
width: src.0,
height: src.1,
})
}
Ok(Err(e)) => {
self.texture = None;
self.thumb = None;
self.source_size = None;
self.error = Some(e.clone());
Some(LoadOutcome::Err(e))
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
self.rx = Some(rx);
None
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
let e = "背景图解码线程异常退出".to_string();
self.error = Some(e.clone());
Some(LoadOutcome::Err(e))
}
}
}
/// 同步载入。测试用 —— 生产路径一律走 [`Self::load_async`]。
#[cfg(test)]
pub fn load(&mut self, ctx: &egui::Context, path: &Path) {
let limit = texture_side_limit(ctx.input(|i| i.max_texture_side));
self.path = Some(path.to_path_buf());
match decode(path, limit) {
Ok((image, thumb, src)) => {
self.source_size = Some(src);
self.thumb = Some(thumb);
self.texture =
Some(ctx.load_texture("nfarms-background", image, egui::TextureOptions::LINEAR));
self.error = None;
}
Err(e) => {
self.texture = None;
self.thumb = None;
self.source_size = None;
self.error = Some(e);
}
}
}
/// 恢复默认背景。
///
/// **已经起跑的解码线程不会停**:`image::decode` 是一整次不可中断的调用,
/// 中途没有检查点可插;这里连 `rx` 一起丢掉,那条线程照旧跑完,
/// 最后 `tx.send` 静默失败(接收端没了,不会 panic)。
/// 代价是白解一张图的 CPU,换来的是"恢复默认"立刻生效、不必等它 ——
/// 这个浪费是有意接受的。真要省,得把解码改成分块 + 取消标志,
/// 那是另一个量级的改动,为这一条不值得。
pub fn clear(&mut self) {
*self = Self::default();
}
/// 铺满整窗:等比放大到刚好盖住,居中裁切 —— 等价于 CSS 的 `background-size: cover`。
pub fn paint(&self, painter: &egui::Painter, rect: egui::Rect) {
let Some(tex) = &self.texture else {
return;
};
let ts = tex.size_vec2();
if ts.x <= 0.0 || ts.y <= 0.0 {
return;
}
painter.image(
tex.id(),
cover_rect(rect, ts),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
/// 取照片上某个屏幕位置的颜色。
///
/// **为什么需要这个**:天气页那张渐变卡片的圆角,是靠「先用 Mesh 铺满整块矩形,
/// 再用背景色把圆角外的一小块盖回去」实现的(egui 没有「带圆角的渐变」这种形状)。
/// 背景是纯色时这招是精确的;背景一旦换成照片,再拿纯色去补就会露出四个色块。
/// 所以这里按同样的 cover 映射,从缩略图上把那个位置的颜色取出来。
pub fn sample(&self, screen: egui::Pos2, screen_rect: egui::Rect) -> Option<egui::Color32> {
let (w, h, px) = self.thumb.as_ref()?;
let (sw, sh) = self.source_size?;
let drawn = cover_rect(screen_rect, egui::vec2(sw as f32, sh as f32));
if drawn.width() <= 0.0 || drawn.height() <= 0.0 {
return None;
}
let u = (screen.x - drawn.left()) / drawn.width();
let v = (screen.y - drawn.top()) / drawn.height();
if !(0.0..=1.0).contains(&u) || !(0.0..=1.0).contains(&v) {
return None;
}
let ix = ((u * (*w as f32)) as usize).min(w - 1);
let iy = ((v * (*h as f32)) as usize).min(h - 1);
let o = (iy * *w + ix) * 4;
Some(egui::Color32::from_rgb(px[o], px[o + 1], px[o + 2]))
}
}
/// 等比放大到刚好盖住 `rect`,居中。
fn cover_rect(rect: egui::Rect, tex_size: egui::Vec2) -> egui::Rect {
let scale = (rect.width() / tex_size.x).max(rect.height() / tex_size.y);
egui::Rect::from_center_size(rect.center(), tex_size * scale)
}
/// 把后端上报的纹理单边上限夹成"解码时能用的上限"。
///
/// **为什么要有这个函数**:原来 `decode` 固定按 [`MAX_SIDE`] 缩放,等于假设
/// "后端上限一定 ≥ 2048"。而 egui 那条 debug_assert 比的是
/// `InputState::max_texture_side` —— 后端不报时是 2048,报的时候可能更小
/// (小显存、软渲染)。一旦它小于 2048,`load_texture` 就会炸在解码之后。
/// 夹一下:比 2048 小就跟着它缩,比 2048 大也只按 2048 缩(见 [`MAX_SIDE`])。
///
/// `clamp(1, ..)` 的下界:`resize(0, 0)` 会 panic,后端真报 0 的话
/// 退化成 1×1 也比整个界面崩掉好。
fn texture_side_limit(reported: usize) -> u32 {
reported.clamp(1, MAX_SIDE as usize) as u32
}
fn decode(path: &Path, max_side: u32) -> Result<Decoded, String> {
let img = image::ImageReader::open(path)
.map_err(|e| format!("打不开文件:{e}"))?
.with_guessed_format()
.map_err(|e| format!("识别格式失败:{e}"))?
.decode()
.map_err(|e| format!("解码失败(支持的格式:{SUPPORTED_FORMATS}):{e}"))?;
let src = (img.width(), img.height());
// 原版也做了同样的降采样;差别只是阈值。
// `max_side` 由调用方给(`load_async` / `load` 取 `InputState::max_texture_side`,
// `decode_for_viewer` 退回 `MAX_SIDE`),**不能**在这里写死常量:
// 上限比 2048 小的后端上,写死就会在 `load_texture` 里炸。
let img = if src.0.max(src.1) > max_side {
img.resize(max_side, max_side, image::imageops::FilterType::Lanczos3)
} else {
img
};
let rgba = img.to_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
// 取色缩略图:三角形滤波缩到 32×32,足够稳,也只占 4KB。
//
// **故意压成正方形、丢掉宽高比**(`sample` 只按归一化 u/v 取色,两轴各自
// 覆盖整幅图,压扁不影响位置映射;理由见 [`THUMB`])。顺带一提,这里的
// `Triangle` 不是点采样:降采样时 `image` 会把滤波核按比例放大,
// 每个输出像素是源图上一块**三角加权平均**的区域色,只是这块区域
// 在 x/y 上不等宽(8:1 的图会是 1:8)。对本用途无所谓 —— 要的是
// "某个位置大概什么颜色",不是精确的区域均值。
let small = image::imageops::resize(&rgba, THUMB as u32, THUMB as u32, image::imageops::FilterType::Triangle);
let thumb = (THUMB, THUMB, small.as_raw().clone());
Ok((
egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()),
thumb,
src,
))
}
/// 「选择图片」对话框。
///
/// 没有引 `rfd` 之类的对话框库 —— 直接调 PowerShell 的
/// `System.Windows.Forms.OpenFileDialog`,也就是原版 C# 用的那个控件。
/// 零新依赖,行为也一致。
///
/// **结果为什么走临时文件、而不是读 stdout**:`[IO.File]::WriteAllText` 按
/// 显式指定的 UTF-8(无 BOM)写、前台 `read_to_string` 按 UTF-8 读,编码钉死;
/// 而 PowerShell 的 stdout 按控制台代码页编码(中文 Windows 上是 936),
/// 用 `Stdio::piped()` 读成 UTF-8 会把含中文的路径读花。管道还有被写满
/// 卡死的风险 —— `status()` 只等退出码、不抽干管道(`output()` 才会)。
/// 代价是脚本必须真的落盘成功,写文件失败跟"用户取消"分不开(见下面的已知缺口)。
///
/// 对话框是**阻塞**的,所以放后台线程、结果用 channel 回传,UI 不卡
/// (原版也强调过「UI 线程不做等待」)。
#[derive(Default)]
pub struct FilePicker {
rx: Option<Receiver<Option<PathBuf>>>,
/// 这次选择是**给谁用**的。
///
/// 工具栏上只有一个对话框,但选出来的东西可能给背景图、音乐、阅读器、
/// 视频用 —— 不含这个标记的话,选完音乐会被当成新背景图。
purpose: PickPurpose,
}
/// 文件选择结果的用途。
//
// 每加一个「选文件」的地方,都必须在这里加一个用途并在 `AppState::poll_background_drop`
// 里接一条路由 —— **否则选出来的东西会落进默认分支去改背景图**。
// 这事真发生过:图片阅读器和 ffmpeg 页的「浏览…」都复用了 `pick_image`,
// 结果选一张图就把整窗背景换掉了。
//
// 这里原先挂着 `#[allow(dead_code)]`,是早期"只有一两个用途在用"时留下的。
// 现在六个变体全都有构造点(core_pages 里的 Music / Video / Viewer /
// FfmpegSrc / BootVideo,`pick_image` 里的 Background)且全都在
// `poll_background_drop` 里被路由,那个 allow 已经没有必要 —— 而它恰好会吞掉
// "新加了一个用途却没人构造"这个警告,也就是上面那段注释要防的事故。
// 本 crate 没有 `deny(warnings)`,默认警告就是唯一的防线,所以删掉它,别再加回来。
//
// (原来的第七个变体 `Reader` 随「文本阅读器」那一页一起删了:新菜单里没有它。
// 删变体的同时**必须删掉 `poll_background_drop` 里那条路由** —— 这正是本枚举
// 上面那段注释说的事故:留着一个没人构造的用途不会报错,只会让下一个人以为
// 它还有人在用。)
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum PickPurpose {
#[default]
Background,
Music,
Video,
/// 图片阅读器:选出来的图要在阅读器里打开,**不能**拿去当背景图
Viewer,
/// ffmpeg 页的「浏览…」:选出来的文件填进源路径输入框
FfmpegSrc,
/// 启动动画的自定义 MP4(背景图页「启动动画」卡片上的「使用自定义 MP4…」)。
///
/// 选出来的文件必须交给 `AppState::boot_video_set(Some(path), ctx)` ——
/// 「记住路径 + 落盘 + 后台抽帧」在那边是一个动作,在路由里拆开写迟早
/// 走散(症状:选了视频、重启后回到内置动画)。
///
/// **别拿 `Video` 顶上**:那条路会把文件甩给视频播放器,在播放器里放一遍 ——
/// 用户点的是"启动动画用它",看到的却是播放器开始放视频,而启动动画这边
/// 路径没记住、帧也没抽。用途和路由对不上出过事故,见本枚举上面那段注释。
BootVideo,
}
/// 系统文件对话框只在 Windows 上有(走 PowerShell + WinForms)。
/// 安卓上没有这个,界面上应当把按钮藏起来、改成提示「直接把图片拖进来」。
pub const PICKER_SUPPORTED: bool = cfg!(windows);
/// 对话框结果的临时文件路径。
///
/// **名字里带 pid + 进程内自增序号**:原来只用 `local_now()` 里的数字字符
/// (形如 `nfarms-pick-20261014213807.txt`),于是"唯一"这件事全靠
/// 那个函数一定含数字、同一秒内不会重入 —— 单实例内确实被 `pick_file` 开头的
/// `self.rx.is_some()` 挡住撞不上,但**两个进程**(桌面版 + 冒烟测试、
/// 或者开了两个实例)共享 `std::env::temp_dir()` 时会写同一个文件,先跑完的
/// 那个被后跑完的覆盖或读走,选出来的东西就不是自己点的那张。
/// 时间戳现在只留着方便排查,唯一性靠 pid + 序号。
fn pick_out_path() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let stamp: String = nfarms_core::time::local_now()
.chars()
.filter(|c| c.is_ascii_digit())
.collect();
std::env::temp_dir().join(format!(
"nfarms-pick-{}-{seq}-{stamp}.txt",
std::process::id()
))
}
impl FilePicker {
pub fn is_pending(&self) -> bool {
self.rx.is_some()
}
/// 图片过滤串(WinForms 语法:`显示名|通配|显示名|通配`)。
pub const IMAGE_FILTER: &str = "图片|*.png;*.jpg;*.jpeg;*.bmp;*.webp|所有文件|*.*";
/// 音频过滤串。
pub const AUDIO_FILTER: &str =
"音频|*.mp3;*.wav;*.wma;*.m4a;*.aac;*.flac;*.ogg|所有文件|*.*";
/// 视频过滤串。
///
/// 三处共用:ffmpeg 工具的「浏览…」(用途 `PickPurpose::FfmpegSrc`)、
/// 视频播放器页(用途 `PickPurpose::Video`)、背景图页的启动动画卡片
/// (用途 `PickPurpose::BootVideo`)。原先的注释写着"视频播放器页会用,
/// 那一页还没实现",那条早就过时了 —— 播放器页已经实现(抽帧上纹理那一套)。
///
/// **启动动画复用它、不另写一条只带 `*.mp4` 的**:启动动画的抽帧走的就是
/// ffmpeg(`lib.rs::extract_boot_frames`,只按 `-i` 的路径解码,不看扩展名),
/// mkv / avi 一样抽得出来。另写一条的代价是两条过滤串各自漂移 —— 哪天这条
/// 加了新容器,启动动画那边还是旧的;用户手上那支动画也未必是 mp4。
/// 界面文案写「MP4」只是给人看的简称。
pub const VIDEO_FILTER: &str =
"视频|*.mp4;*.mkv;*.avi;*.mov;*.webm;*.flv;*.ts;*.m4v|所有文件|*.*";
/// 通用文件选择。
///
/// 原来只有 `pick_image`,过滤串写死在 PowerShell 脚本里 —— 音乐、阅读器、
/// 视频三页都要选文件,各写一份就太多了。抽成参数,标题与过滤串由调用方给。
pub fn pick_file(&mut self, ctx: &egui::Context, purpose: PickPurpose, title: &str, filter: &str) {
if !PICKER_SUPPORTED || self.rx.is_some() {
return;
}
self.purpose = purpose;
// 单引号会截断 PowerShell 的单引号字符串 —— 我们的调用方只传常量,
// 但还是防一手,免得以后有人从界面拼一个标题进来。
let title = title.replace('\'', " ");
let filter = filter.replace('\'', " ");
let out = pick_out_path();
let _ = std::fs::remove_file(&out);
// -STA:WinForms 对话框要求单线程单元
let script = format!(
"Add-Type -AssemblyName System.Windows.Forms | Out-Null; \
$d = New-Object System.Windows.Forms.OpenFileDialog; \
$d.Title = '{title}'; \
$d.Filter = '{filter}'; \
$d.CheckFileExists = $true; \
if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ \
[IO.File]::WriteAllText('{}', $d.FileName, (New-Object System.Text.UTF8Encoding $false)) }}",
out.display()
);
let (tx, rx) = std::sync::mpsc::channel();
self.rx = Some(rx);
let ctx = ctx.clone();
std::thread::spawn(move || {
let mut cmd = std::process::Command::new("powershell.exe");
cmd.args(["-NoProfile", "-STA", "-Command", &script])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
// 已知缺口(补它要动 `lib.rs`,超出本文件,这里只留个坐标):
// 退出码与 `io::Error` 都被丢掉,加上 stderr 也是 `Stdio::null()`,
// 于是"PowerShell 被策略禁掉 / 脚本压根没跑起来"与"用户点了取消"
// 在结果上完全同形 —— 都是临时文件没内容 → `None`。
// `poll_background_drop`(`lib.rs:1424`)拿到 `None` 也不写日志,
// 所以界面上和日志里都不会留痕。补法:把 `status()` 的结果连同
// stderr(改 `Stdio::piped()`,注意 `status()` 不抽干管道)一起塞进
// channel,让消费端能区分"没跑起来"和"取消了"——那要改
// `FilePicker::poll` 的返回类型和 `lib.rs` 的调用点。
let _ = cmd.status();
let picked = std::fs::read_to_string(&out)
.ok()
.map(|s| PathBuf::from(s.trim()))
.filter(|p| p.is_file());
let _ = std::fs::remove_file(&out);
let _ = tx.send(picked);
ctx.request_repaint();
});
}
pub fn pick_image(&mut self, ctx: &egui::Context) {
self.pick_file(
ctx,
PickPurpose::Background,
"选择背景图片",
Self::IMAGE_FILTER,
);
}
/// 每帧轮询一次。返回(用途, 选中的路径)。
pub fn poll(&mut self) -> Option<(PickPurpose, PathBuf)> {
let rx = self.rx.as_ref()?;
match rx.try_recv() {
Ok(r) => {
self.rx = None;
r.map(|p| (self.purpose, p))
}
Err(std::sync::mpsc::TryRecvError::Empty) => None,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// 已知缺口(补它要动 `lib.rs`):这里和 `Background::poll` 的
// `Disconnected` 分支不对称 —— 那边会造一条"背景图解码线程异常退出"
// 交给 `lib.rs:1408` 写日志,这边只能返回 `None`,而
// `poll_background_drop`(`lib.rs:1424`)对 `None` 不做任何记录。
// 要留痕得让本函数能同时回传错误(改返回类型)或在 `FilePicker`
// 上加一个会被消费的 `error` 字段,两条路都要改 `lib.rs`。
// (命中条件也苛刻:得 `pick_file` 起的那条线程 panic 才行。)
self.rx = None;
None
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cover_rect_fills_and_centers() {
let win = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100.0, 50.0));
// 方图铺进宽窗:按宽度放大,高度溢出后被裁掉
let r = cover_rect(win, egui::vec2(10.0, 10.0));
assert_eq!(r.width(), 100.0);
assert_eq!(r.height(), 100.0);
assert_eq!(r.center(), win.center());
// 必须完全盖住窗口,不能留边
assert!(r.left() <= win.left() && r.right() >= win.right());
assert!(r.top() <= win.top() && r.bottom() >= win.bottom());
}
#[test]
fn cover_rect_handles_tall_image_in_wide_window() {
let win = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(200.0, 100.0));
// 很瘦很高的图(10×40)铺进宽窗口:按**宽度**放大 20 倍,
// 高度变成 800 —— 远超窗口,这正是 cover 该有的行为(宁可裁掉也不能留边)。
let r = cover_rect(win, egui::vec2(10.0, 40.0));
assert_eq!(r.width(), 200.0);
assert_eq!(r.height(), 800.0);
assert_eq!(r.center(), win.center());
assert!(r.top() <= win.top() && r.bottom() >= win.bottom());
}
#[test]
fn missing_file_is_reported_not_panicked() {
let mut bg = Background::default();
let ctx = egui::Context::default();
bg.load(&ctx, Path::new(r"C:\definitely\not\here.png"));
assert!(!bg.is_set());
assert!(bg.error.is_some());
assert!(bg.sample(egui::pos2(0.0, 0.0), egui::Rect::ZERO).is_none());
bg.clear();
assert!(bg.error.is_none() && bg.path.is_none());
}
/// 端到端:真的写一个 PNG 到磁盘,再走完整条「打开 → 识别 → 解码 →
/// 降采样 → 上传纹理 → 按屏幕坐标取色」的链路。
/// 上面那个 sample 测试用的是手工塞进去的假缩略图,这条才是真解码。
#[test]
fn loads_a_real_png_and_samples_it() {
let dir = std::env::temp_dir().join("nfarms-bg-real");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("half-red-half-blue.png");
// 左半红、右半蓝
let mut img = image::RgbaImage::new(64, 64);
for (x, _y, px) in img.enumerate_pixels_mut() {
*px = if x < 32 {
image::Rgba([255, 0, 0, 255])
} else {
image::Rgba([0, 0, 255, 255])
};
}
img.save(&path).expect("写测试图");
let ctx = egui::Context::default();
let mut bg = Background::default();
bg.load(&ctx, &path);
assert!(bg.error.is_none(), "解码不该失败: {:?}", bg.error);
assert!(bg.is_set(), "纹理应当已上传");
assert_eq!(bg.source_size, Some((64, 64)));
let win = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100.0, 100.0));
let left = bg.sample(egui::pos2(20.0, 50.0), win).expect("左侧取色");
let right = bg.sample(egui::pos2(80.0, 50.0), win).expect("右侧取色");
assert!(left.r() > 200 && left.b() < 60, "左半应当偏红: {left:?}");
assert!(right.b() > 200 && right.r() < 60, "右半应当偏蓝: {right:?}");
let _ = std::fs::remove_dir_all(&dir);
}
/// 超大图必须被降采样,否则每帧缩放会拖垮帧率。
#[test]
fn huge_image_is_downscaled_before_upload() {
let dir = std::env::temp_dir().join("nfarms-bg-huge");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.png");
let img = image::RgbaImage::from_pixel(4000, 2000, image::Rgba([10, 200, 10, 255]));
img.save(&path).unwrap();
let ctx = egui::Context::default();
let mut bg = Background::default();
bg.load(&ctx, &path);
assert!(bg.is_set());
// 原图尺寸如实记录
assert_eq!(bg.source_size, Some((4000, 2000)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sample_maps_screen_to_image() {
// 造一个假缩略图:左半红、右半蓝
let mut px = vec![0u8; THUMB * THUMB * 4];
for y in 0..THUMB {
for x in 0..THUMB {
let o = (y * THUMB + x) * 4;
if x < THUMB / 2 {
px[o] = 255; // R
} else {
px[o + 2] = 255; // B
}
px[o + 3] = 255;
}
}
let mut bg = Background {
source_size: Some((64, 64)),
thumb: Some((THUMB, THUMB, px)),
..Default::default()
};
let win = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100.0, 100.0));
let left = bg.sample(egui::pos2(10.0, 50.0), win).unwrap();
let right = bg.sample(egui::pos2(90.0, 50.0), win).unwrap();
assert!(left.r() > 200 && left.b() < 50, "左半应当是红的: {left:?}");
assert!(right.b() > 200 && right.r() < 50, "右半应当是蓝的: {right:?}");
bg.clear();
}
// ---- 下面这几条是审计修复带回来的回归测试 ----
/// 临时文件名必须**进程内唯一**,不能只靠 `local_now()` 里的数字:
/// 原实现同秒内两次调用会拿到同一个名字(跨进程互踩)。
/// 名字里还必须带 pid,否则两个进程照样撞。
#[test]
fn pick_out_path_is_unique_and_process_scoped() {
let a = pick_out_path();
let b = pick_out_path();
assert_ne!(a, b, "同一秒内两次调用必须给出不同文件:{a:?}");
let name = a.file_name().unwrap().to_string_lossy().to_string();
assert!(
name.contains(&std::process::id().to_string()),
"名字里要带进程号,否则多进程仍会互踩:{name}"
);
assert!(name.starts_with("nfarms-pick-"), "名字形状变了:{name}");
assert!(name.ends_with(".txt"), "名字形状变了:{name}");
}
/// 「解码失败」文案里的格式列表必须与 manifest 里 `image` 的 features 对齐。
/// 原实现是写死的字面量,特性加了新格式文案不会跟着变 —— 用户按提示
/// 换一种格式还是打不开,而且不知道是被文案骗了。
#[test]
fn decode_error_message_lists_manifest_features() {
let dir = std::env::temp_dir().join("nfarms-bg-fmt");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// "能打开、但认不出是图片"的文件:报错必然走「解码失败(支持的格式:…)」
let path = dir.join("not-an-image.bin");
std::fs::write(&path, b"definitely not an image").unwrap();
// 不用 `expect_err`:`Decoded` 里的 `ColorImage` 没实现 `Debug`
let err = match decode(&path, MAX_SIDE) {
Ok(_) => panic!("这不是图片,必须报错"),
Err(e) => e,
};
assert!(
err.contains(SUPPORTED_FORMATS),
"文案必须由 SUPPORTED_FORMATS 拼出来(单一来源),实际:{err}"
);
// 再把 manifest 里的特性列表抠出来逐项对齐
let manifest =
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")).unwrap();
let line = match manifest
.lines()
.find(|l| l.trim_start().starts_with("image = "))
{
Some(l) => l,
None => {
// 依赖改名/换写法:只提示,不当失败 —— 免得重构 manifest 就红一片
eprintln!("跳过 manifest 对齐:找不到 image 依赖行");
let _ = std::fs::remove_dir_all(&dir);
return;
}
};
let Some(list) = line
.split("features = [")
.nth(1)
.and_then(|s| s.split(']').next())
else {
eprintln!("跳过 manifest 对齐:{line}");
let _ = std::fs::remove_dir_all(&dir);
return;
};
for feat in list.split(',') {
let feat = feat.trim().trim_matches('"').trim();
if feat.is_empty() {
continue;
}
// 唯一一处名字对不上:image 的特性叫 jpeg,界面文案写 jpg
let names: Vec<&str> = if feat == "jpeg" {
vec!["jpg", "jpeg"]
} else {
vec![feat]
};
assert!(
names.iter().any(|n| SUPPORTED_FORMATS.contains(n)),
"manifest 开着 `{feat}` 特性,文案「{SUPPORTED_FORMATS}」里却没有 —— \
用户会按提示换格式却依然打不开"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
/// 解码上限必须**同时**服从后端上报值:比 MAX_SIDE 小就得跟着缩
/// (否则 `load_texture` 的 debug_assert 会在解码之后才炸),
/// 比 MAX_SIDE 大也只按 MAX_SIDE 缩(保守,省显存)。
#[test]
fn texture_side_limit_never_exceeds_the_reported_limit() {
assert_eq!(texture_side_limit(16384), MAX_SIDE, "比 2048 大也只按 2048");
assert_eq!(texture_side_limit(MAX_SIDE as usize), MAX_SIDE);
assert_eq!(texture_side_limit(1024), 1024, "后端只给 1024 就必须缩到 1024");
assert_eq!(texture_side_limit(1), 1);
assert_eq!(texture_side_limit(0), 1, "0 会让 resize(0,0) panic");
}
/// 上一条的行为面:上限变小,解出来的图真的跟着变小。
#[test]
fn decode_obeys_a_smaller_texture_limit() {
let dir = std::env::temp_dir().join("nfarms-bg-limit");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("wide.png");
image::RgbaImage::from_pixel(400, 200, image::Rgba([7, 8, 9, 255]))
.save(&path)
.unwrap();
let (img, _thumb, src) = decode(&path, 64).expect("解码");
assert_eq!(src, (400, 200), "原图尺寸要如实记录");
assert!(
img.width().max(img.height()) <= 64,
"必须缩进上限之内,否则上传纹理时会 panic:{:?}",
img.size
);
let _ = std::fs::remove_dir_all(&dir);
}
/// 缩略图**故意**压成正方形(丢宽高比):8:1 的原图也是 32×32。
/// 谁要是"顺手修正宽高比"改成等比内接,这条会红 —— 那时必须先想清楚
/// `sample` 的归一化映射和补边问题(见 `THUMB` 的注释)。
#[test]
fn wide_image_thumb_stays_square_on_purpose() {
let dir = std::env::temp_dir().join("nfarms-bg-thumb");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("wide.png");
// 左半红、右半蓝的 8:1 长条
let mut img = image::RgbaImage::new(256, 32);
for (x, _y, px) in img.enumerate_pixels_mut() {
*px = if x < 128 {
image::Rgba([255, 0, 0, 255])
} else {
image::Rgba([0, 0, 255, 255])
};
}
img.save(&path).unwrap();
let (_img, (tw, th, px), src) = decode(&path, MAX_SIDE).expect("解码");
assert_eq!(src, (256, 32));
assert_eq!((tw, th), (THUMB, THUMB), "8:1 的原图也压成正方形,这是有意的");
assert_eq!(px.len(), THUMB * THUMB * 4, "缩略图缓冲区大小要与自报尺寸一致");
// 压扁之后位置映射仍然对:左端取红、右端取蓝
let bg = Background {
source_size: Some((256, 32)),
thumb: Some((tw, th, px)),
..Default::default()
};
let win = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(256.0, 32.0));
let left = bg.sample(egui::pos2(32.0, 16.0), win).expect("左侧取色");
let right = bg.sample(egui::pos2(224.0, 16.0), win).expect("右侧取色");
assert!(left.r() > 200 && left.b() < 60, "左端应当偏红:{left:?}");
assert!(right.b() > 200 && right.r() < 60, "右端应当偏蓝:{right:?}");
let _ = std::fs::remove_dir_all(&dir);
}
/// 载入中又来一次载入:后一次必须生效(用户最后选的那张赢),同时
/// 被顶掉的那次要在 `error` 里留痕 —— 设置页读的就是这个字段
/// (`core_pages.rs:277`)。原来被顶掉那次是彻底静默的:
/// 它只会在 `tx.send` 上失败,日志和界面都不会有任何记录。
#[test]
fn superseded_load_is_recorded_in_error() {
let ctx = egui::Context::default();
let mut bg = Background::default();
bg.load_async(&ctx, Path::new(r"C:\definitely\not\here-a.png"));
assert!(bg.is_loading(), "刚起线程,rx 应当在");
assert!(bg.error.is_none(), "第一次载入不该有提示:{:?}", bg.error);
bg.load_async(&ctx, Path::new(r"C:\definitely\not\here-b.png"));
assert_eq!(
bg.path,
Some(PathBuf::from(r"C:\definitely\not\here-b.png")),
"后一次选的路径必须生效"
);
assert!(
bg.error.as_deref().unwrap_or_default().contains("取代"),
"被顶掉的那次要在 error 里留痕,实际:{:?}",
bg.error
);
}
}