跳转至

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