跳转至

crates\nfarms-gui\src\viewer.rs

源码路径:crates\nfarms-gui\src\viewer.rs · 625 行

//! 图片阅读器 —— Player 组里的「图片阅读器」那一页。
//!
//! 复用 `background.rs` 的解码(同一个降采样阈值、同一份格式错误文案),
//! 但显示逻辑完全不同:背景图是 cover(铺满、裁掉多余),
//! 阅读器是 contain(完整看清、留白),而且支持缩放与旋转。
//!
//! 解码同样在后台线程跑:一张 4000×3000 的照片解一次要几百毫秒,
//! 放在 UI 线程里切下一张就会卡一下 —— 原版 README 反复强调「UI 线程不做等待」。

use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, TryRecvError};

use eframe::egui;
use egui::{Color32, RichText};

use crate::background;

/// 阅读器能打开的扩展名(与 `image` 依赖开的特性一致)。
const EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "bmp", "webp", "gif"];

/// 画布上方那些「顶部装饰」占掉的高度(面包屑 + 页标题 + 副标题 + 参数卡片 + 工具条)。
///
/// **为什么要减它**:这一页画在一个 `auto_shrink=false` 的竖向 `ScrollArea` 里,
/// `available_size().y` 给的是可滚动内容的可用高度,而不是窗口的可见高度;
/// 照它分配画布,图就会按宽度放大到超出窗口下沿(见 `canvas()` 里的注释)。
///
/// 这是一份**估算值**,不是精确测量:精确值要等这一帧的卡片/工具条画完才知道,
/// 而画布是在其后同一帧里分配的。估算偏大的后果只是画布矮一点(图小一圈),
/// 偏小才会重新出现「下沿被切」。真要精确,得把画布改成
/// `ui.available_height()` 之后再回填,那会让 `ui()` 的布局顺序和工具条耦合 —— 不值。
const TOP_CHROME_H: f32 = 260.0;

/// 画布的最小高度。窗口再矮也不能把画布压成一条缝,否则图上什么都看不见。
const CANVAS_MIN_H: f32 = 180.0;

/// 画布的最小宽度。
const CANVAS_MIN_W: f32 = 160.0;

/// 页面上想做的事,由 `core_pages` 里的绘制函数负责执行。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ViewerAction {
    /// 把当前这张设为窗口背景
    SetAsBackground(PathBuf),
}

type Decoded = (egui::ColorImage, (u32, u32));

pub struct ImageViewer {
    path: Option<PathBuf>,
    /// 当前图片所在目录(用来翻上一张/下一张)
    dir: Option<PathBuf>,
    /// 同目录下的全部图片,按文件名排序
    files: Vec<PathBuf>,
    index: usize,
    texture: Option<egui::TextureHandle>,
    /// 原图尺寸(降采样前)
    pub source_size: Option<(u32, u32)>,
    pub error: Option<String>,
    /// 1.0 = 适应窗口
    pub zoom: f32,
    /// 0 / 90 / 180 / 270
    pub rotation: i32,
    rx: Option<Receiver<Result<Decoded, String>>>,
    action: Option<ViewerAction>,
}

impl Default for ImageViewer {
    fn default() -> Self {
        Self {
            path: None,
            dir: None,
            files: Vec::new(),
            index: 0,
            texture: None,
            source_size: None,
            error: None,
            zoom: 1.0,
            rotation: 0,
            rx: None,
            action: None,
        }
    }
}

impl ImageViewer {
    pub fn path(&self) -> Option<&Path> {
        self.path.as_deref()
    }

    pub fn is_loading(&self) -> bool {
        self.rx.is_some()
    }

    /// 同目录里第几张 / 共几张。
    ///
    /// 列表为空时返回 `(0, 0)` 而不是 `(1, 0)`:工具条每帧都画这段文字,
    /// 一进这一页、什么都还没打开的时候 `index` 是 0,`index + 1` 会显示出
    /// 一个不存在的「第 1 张」。`(0, 0)` 读作「没有第几张,共 0 张」,不骗人;
    /// 调用方(`core_pages` 的悬停提示)也据此拿得到「一张都没有」这个事实。
    pub fn position(&self) -> (usize, usize) {
        if self.files.is_empty() {
            (0, 0)
        } else {
            (self.index + 1, self.files.len())
        }
    }

    pub fn dir(&self) -> Option<&Path> {
        self.dir.as_deref()
    }

    pub fn take_action(&mut self) -> Option<ViewerAction> {
        self.action.take()
    }

    /// 载入一张图(后台解码)。同时刷新同目录文件列表。
    pub fn load_async(&mut self, ctx: &egui::Context, path: &Path) {
        let path = path.to_path_buf();
        let dir = path.parent().map(|p| p.to_path_buf());

        // **每次**载入都重扫,而不是只在目录变化时重扫。
        //
        // 原来只比较 `self.dir != dir`:同目录里刚存进来的图、刚删掉的图
        // 都看不见,`files` 一直是打开第一张时的那份快照,要翻到
        // 「下一张」才发现列表是旧的(少了新图或指向已删除的文件)。
        // 目录项数量很小,每次翻页重扫一遍 `read_dir` 的代价可以忽略。
        //
        // 扫描放在**当前线程**上同步做完(不是丢进下面那个解码线程):
        // 它只读目录、不碰 `egui`,而且 `self.files` 要立刻就能给
        // `position()` / `step()` 用 —— 异步化只会让翻页的头一帧看到旧列表。
        //
        // 清空 `self.error` 必须排在 `adopt_dir` **前面**,否则「目录读不开」
        // 这条错误刚写进去就被清掉,用户又回到「这个文件夹里没有图片」。
        // 清空本身是必需的:换一张图不能继续挂着上一张的解码错误。
        // 真解码失败了,`poll()` 会用解码错误覆盖这条扫描错误 ——
        // 对同一张图来说,「解码失败」比「顺带扫目录也失败了」更贴近现象。
        self.error = None;
        self.adopt_dir(dir.as_deref(), &path);

        self.zoom = 1.0;
        self.rotation = 0;
        self.path = Some(path.clone());

        let (tx, rx) = std::sync::mpsc::channel();
        self.rx = Some(rx);
        let ctx = ctx.clone();
        std::thread::spawn(move || {
            let out = background::decode_for_viewer(&path);
            let _ = tx.send(out);
            ctx.request_repaint();
        });
    }

    /// 重扫 `dir` 并定位 `path` 在列表里的下标,顺带记录扫描错误。
    ///
    /// 从 `load_async` 里拆出来是为了**能测**:剩下的部分是起线程解码,
    /// 在单元测试里拿到结果要跟线程赛跑,而重扫/定位这段逻辑本身是纯的。
    fn adopt_dir(&mut self, dir: Option<&Path>, path: &Path) {
        self.dir = dir.map(Path::to_path_buf);
        let scanned = match dir {
            Some(d) => scan_images(d),
            // 没有父目录(路径本身就是根,或只有一个文件名)——
            // 这不是错误,只是没有可翻的目录。
            None => Ok(Vec::new()),
        };
        let (files, scan_err) = match scanned {
            Ok(files) => (files, None),
            Err(e) => (Vec::new(), Some(e)),
        };
        self.files = files;
        // 找不到就保留原来的下标:这时往往正是「刚载入的这张不在列表里」
        // (扩展名不在白名单、或文件已经被删)。跳回 0 会让「下一张」
        // 从目录头上开始、莫名绕圈,保留下标至少是站在原地。
        self.index = self
            .files
            .iter()
            .position(|p| p == path)
            .unwrap_or(self.index);

        // 目录读失败要**说出来**。以前这里只有 `unwrap_or_default()`,
        // 后果是「这个文件夹里没有图片」—— 用户完全看不出真因(权限、盘掉线),
        // 会把「打不开目录」当成「目录里确实没图」。
        self.error = scan_err;
    }

    /// 轮询解码结果。返回 `Some(描述)` 表示这一帧刚有结果(成功或失败)。
    pub fn poll(&mut self, ctx: &egui::Context) -> Option<String> {
        let rx = self.rx.take()?;
        match rx.try_recv() {
            Ok(Ok((img, src))) => {
                self.texture = Some(ctx.load_texture("image-viewer", img, Default::default()));
                self.source_size = Some(src);
                let name = self
                    .path
                    .as_ref()
                    .and_then(|p| p.file_name())
                    .map(|n| n.to_string_lossy().to_string())
                    .unwrap_or_default();
                Some(format!("已载入 {name} ({}×{})", src.0, src.1))
            }
            Ok(Err(e)) => {
                self.texture = None;
                self.source_size = None;
                self.error = Some(e.clone());
                Some(format!("载入失败: {e}"))
            }
            Err(TryRecvError::Empty) => {
                self.rx = Some(rx);
                None
            }
            Err(TryRecvError::Disconnected) => {
                self.error = Some("解码线程异常退出".to_string());
                Some("解码线程异常退出".to_string())
            }
        }
    }

    /// 翻到同目录里的下一张 / 上一张。到底了就绕回去 —— 翻图时不想被「到头了」卡住。
    pub fn step(&mut self, ctx: &egui::Context, delta: isize) {
        if self.files.len() < 2 {
            return;
        }
        let n = self.files.len() as isize;
        let mut i = (self.index as isize + delta) % n;
        if i < 0 {
            i += n;
        }
        let next = self.files[i as usize].clone();
        self.load_async(ctx, &next);
    }

    pub fn zoom_by(&mut self, factor: f32) {
        // 上限 8× / 下限 0.05×:再大只是糊,再小就没意义了
        self.zoom = (self.zoom * factor).clamp(0.05, 8.0);
    }

    pub fn reset_zoom(&mut self) {
        self.zoom = 1.0;
    }

    pub fn rotate(&mut self, delta: i32) {
        self.rotation = (self.rotation + delta).rem_euclid(360);
    }

    pub fn request_set_background(&mut self) {
        if let Some(p) = &self.path {
            self.action = Some(ViewerAction::SetAsBackground(p.clone()));
        }
    }

    /// 画图。`toolbar` 为 true 时连工具条一起画(页面里用),false 时只画图。
    pub fn ui(&mut self, ui: &mut egui::Ui, toolbar: bool) {
        if toolbar {
            self.toolbar(ui);
            ui.add_space(10.0);
        }
        self.canvas(ui);
    }

    fn toolbar(&mut self, ui: &mut egui::Ui) {
        let mut step: isize = 0;
        let mut zoom_factor = 1.0_f32;
        let mut reset = false;
        let mut rotate = 0;
        let mut set_bg = false;

        ui.horizontal(|ui| {
            if ui
                .add_enabled(self.files.len() > 1, egui::Button::new("上一张"))
                .clicked()
            {
                step = -1;
            }
            if ui
                .add_enabled(self.files.len() > 1, egui::Button::new("下一张"))
                .clicked()
            {
                step = 1;
            }
            let (i, n) = self.position();
            ui.label(
                RichText::new(format!("{i} / {n}"))
                    .size(12.0)
                    .color(crate::theme::TEXT_SUB),
            );
            ui.separator();
            if ui.button("缩小").clicked() {
                zoom_factor = 1.0 / 1.25;
            }
            if ui.button("放大").clicked() {
                zoom_factor = 1.25;
            }
            if ui.button("适应窗口").clicked() {
                reset = true;
            }
            ui.label(
                RichText::new(format!("{:.0}%", self.zoom * 100.0))
                    .size(12.0)
                    .color(crate::theme::TEXT_SUB),
            );
            ui.separator();
            if ui.button("左转").clicked() {
                rotate = -90;
            }
            if ui.button("右转").clicked() {
                rotate = 90;
            }
            ui.separator();
            if ui
                .add_enabled(self.path.is_some(), egui::Button::new("设为背景"))
                .clicked()
            {
                set_bg = true;
            }
        });

        if step != 0 {
            // 这里没有 ctx,交给调用方在下一帧处理更麻烦 —— 直接用 ui.ctx()
            let ctx = ui.ctx().clone();
            self.step(&ctx, step);
        }
        if zoom_factor != 1.0 {
            self.zoom_by(zoom_factor);
        }
        if reset {
            self.reset_zoom();
        }
        if rotate != 0 {
            self.rotate(rotate);
        }
        if set_bg {
            self.request_set_background();
        }
    }

    fn canvas(&mut self, ui: &mut egui::Ui) {
        // 画布占满剩余空间,滚轮缩放。
        //
        // 高度**必须夹到窗口可视高度以内**:这一页的绘制函数跑在一个
        // `auto_shrink=false` 的竖向 ScrollArea 里,`available_size().y`
        // 给的是可滚动内容区的高度(可以远大于窗口),照它分配就会出现
        // 「图按 width 适配放大、下沿被窗口切掉」的情况。
        let avail = ui.available_size();
        let max_h = (ui.ctx().screen_rect().height() - TOP_CHROME_H).max(CANVAS_MIN_H);
        let canvas = egui::vec2(avail.x.max(CANVAS_MIN_W), avail.y.min(max_h));
        let (rect, resp) = ui.allocate_exact_size(canvas, egui::Sense::hover());
        let p = ui.painter_at(rect);
        p.rect_filled(rect, 8.0, crate::theme::APP_BG);

        if let Some(err) = &self.error {
            p.text(
                rect.center(),
                egui::Align2::CENTER_CENTER,
                err,
                egui::FontId::proportional(13.0),
                crate::theme::DANGER,
            );
            return;
        }
        // 先把纹理的 id 和尺寸**拷出来**:后面要 `self.zoom_by()`,
        // 而这里如果还借着 `self.texture`,可变借用就会被拒绝。
        let Some((tex_id, tex_size)) = self
            .texture
            .as_ref()
            .map(|t| (t.id(), t.size_vec2()))
        else {
            p.text(
                rect.center(),
                egui::Align2::CENTER_CENTER,
                if self.is_loading() {
                    "解码中…"
                } else {
                    "还没有打开图片 —— 用上面的「选择图片」,或把图片拖进窗口"
                },
                egui::FontId::proportional(13.0),
                crate::theme::TEXT_SUB,
            );
            return;
        };

        // 滚轮缩放(以指针位置为锚点会让实现复杂很多,这里以中心缩放,够用)
        if resp.hovered() {
            let scroll = ui.input(|i| i.smooth_scroll_delta.y);
            if scroll.abs() > 0.1 {
                self.zoom_by(if scroll > 0.0 { 1.08 } else { 1.0 / 1.08 });
            }
        }

        let rotated = self.rotation % 180 != 0;
        let shown = if rotated {
            egui::vec2(tex_size.y, tex_size.x)
        } else {
            tex_size
        };
        // contain:完整看清,不裁切(和背景图的 cover 相反)
        let fit = (rect.width() / shown.x).min(rect.height() / shown.y);
        let size = shown * fit * self.zoom;
        draw_rotated(&p, tex_id, rect.center(), size, self.rotation);
    }
}

/// 把纹理按任意角度画出来。
///
/// egui 没有「旋转的图片」这种形状,但 `Mesh` 有:四个顶点自己算位置,
/// UV 仍然是规规矩矩的 0..1,GPU 会把整张图跟着四个角一起转。
fn draw_rotated(
    painter: &egui::Painter,
    tex_id: egui::TextureId,
    center: egui::Pos2,
    size: egui::Vec2,
    rotation_deg: i32,
) {
    let a = (rotation_deg as f32).to_radians();
    let (sin, cos) = a.sin_cos();
    let half = size / 2.0;

    // 四个角(顺时针,左上开始),未旋转时的局部坐标
    let corners = [(-1.0_f32, -1.0_f32), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)];
    let mut mesh = egui::Mesh::with_texture(tex_id);
    for (lx, ly) in corners {
        let dx = lx * half.x;
        let dy = ly * half.y;
        // 旋转
        let rx = dx * cos - dy * sin;
        let ry = dx * sin + dy * cos;
        mesh.vertices.push(egui::epaint::Vertex {
            pos: egui::pos2(center.x + rx, center.y + ry),
            uv: egui::pos2((lx + 1.0) / 2.0, (ly + 1.0) / 2.0),
            color: Color32::WHITE,
        });
    }
    mesh.indices = vec![0, 1, 2, 0, 2, 3];
    painter.add(egui::Shape::mesh(mesh));
}

/// 列出目录里能打开的图片,按文件名排序(资源管理器也是这个顺序)。
///
/// 返回 `Err` 只表示**目录整体读不开**(权限不足、盘掉线、路径已不存在)——
/// 这时界面上要出错误文字,而不是装作「这个文件夹里没有图片」。
///
/// 单个目录项读失败(`entries.flatten()` 丢掉的那些)仍然只是跳过:
/// 一个坏项不该把整份列表判死,而且它并不影响用户能不能看到其余图片,
/// 为此弹一条错误反而会盖住已经载入好的图。这是有意的取舍,不是漏掉。
fn scan_images(dir: &Path) -> Result<Vec<PathBuf>, String> {
    let entries = std::fs::read_dir(dir)
        .map_err(|e| format!("打不开目录 {}:{e}", dir.display()))?;
    let mut out: Vec<PathBuf> = entries
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.is_file())
        .filter(|p| {
            p.extension()
                .and_then(|e| e.to_str())
                .map(|e| EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
                .unwrap_or(false)
        })
        .collect();
    out.sort();
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// 每个测试用**自己**的临时目录。
    ///
    /// 原来这个模块里写死 `temp_dir()/nfarms-viewer-test`:两个测试并行跑、
    /// 或者上一次跑崩了留下残留文件,都会互相干扰。名字里带进程号 + 计数器,
    /// 并且**开头先删一次**残留(结尾那次清理在 panic / 进程被杀时不会执行)。
    fn temp_dir(tag: &str) -> PathBuf {
        use std::sync::atomic::{AtomicU32, Ordering};
        static N: AtomicU32 = AtomicU32::new(0);
        let n = N.fetch_add(1, Ordering::Relaxed);
        let dir = std::env::temp_dir().join(format!(
            "nfarms-viewer-test-{tag}-{}-{n}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("建临时目录");
        dir
    }

    fn names(dir: &Path) -> Vec<String> {
        scan_images(dir)
            .expect("目录应当能读")
            .iter()
            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
            .collect()
    }

    #[test]
    fn scan_filters_by_extension_and_sorts() {
        let dir = temp_dir("scan");
        for name in ["b.PNG", "a.jpg", "note.txt", "c.webp"] {
            let _ = std::fs::write(dir.join(name), b"x");
        }
        assert_eq!(
            names(&dir),
            vec!["a.jpg", "b.PNG", "c.webp"],
            "txt 不该进来,且要排序"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 目录读不开的时候必须返回 `Err`,不能装作「这里没有图片」。
    ///
    /// 用一个**文件**当目录参数:`read_dir` 在任何平台上都会失败
    /// (Windows:`NotADirectory`),这就复现了「路径已经不是目录了」这一类真因。
    #[test]
    fn scan_reports_unreadable_dir() {
        let dir = temp_dir("unreadable");
        let not_a_dir = dir.join("not-a-dir.png");
        std::fs::write(&not_a_dir, b"x").unwrap();
        let err = scan_images(&not_a_dir).expect_err("对文件调 read_dir 必然失败");
        assert!(
            err.contains("打不开目录"),
            "错误文案要说明是目录的问题,实际:{err}"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 目录读不开时,`load_async` 要把错误摆到界面上(`self.error`),
    /// 而不是留给用户一个「这个文件夹里没有图片」。
    #[test]
    fn load_async_surfaces_dir_error() {
        let dir = temp_dir("direrr");
        let sub = dir.join("sub");
        std::fs::create_dir_all(&sub).unwrap();
        let img = sub.join("a.png");
        std::fs::write(&img, b"x").unwrap();
        // 把目录换成文件:父目录 `sub` 已经不是目录了
        std::fs::remove_dir_all(&sub).unwrap();
        std::fs::write(&sub, b"x").unwrap();

        let ctx = egui::Context::default();
        let mut v = ImageViewer::default();
        v.load_async(&ctx, &img);
        let err = v.error.as_deref().unwrap_or_default();
        assert!(
            err.contains("打不开目录"),
            "目录错误要写进 self.error,实际:{err:?}"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 同目录里加了一张图,**下一次载入**就该看见它。
    ///
    /// 原来是「只有目录变了才重扫」,`files` 会一直停在打开第一张时的快照;
    /// 这里第二次载入的目录与第一次完全相同,靠的就是重扫。
    #[test]
    fn load_async_rescans_within_same_dir() {
        let dir = temp_dir("rescan");
        let a = dir.join("a.png");
        let b = dir.join("b.png");
        std::fs::write(&a, b"x").unwrap();
        let ctx = egui::Context::default();

        let mut v = ImageViewer::default();
        v.load_async(&ctx, &a);
        assert_eq!(v.position(), (1, 1), "刚开始只有 a.png");

        // 用户在这个目录里又存了一张 —— 目录没变
        std::fs::write(&b, b"x").unwrap();
        v.load_async(&ctx, &b);
        assert_eq!(
            v.position(),
            (2, 2),
            "同目录新增的图必须在重扫后出现,且新图的下标要算对"
        );
        assert_eq!(v.dir(), Some(dir.as_path()));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn zoom_is_clamped() {
        let mut v = ImageViewer::default();
        for _ in 0..100 {
            v.zoom_by(2.0);
        }
        assert!(v.zoom <= 8.0 + f32::EPSILON);
        for _ in 0..200 {
            v.zoom_by(0.5);
        }
        assert!(v.zoom >= 0.05 - f32::EPSILON);
    }

    #[test]
    fn rotation_wraps_around() {
        let mut v = ImageViewer::default();
        v.rotate(90);
        v.rotate(90);
        v.rotate(90);
        v.rotate(90);
        assert_eq!(v.rotation, 0);
        v.rotate(-90);
        assert_eq!(v.rotation, 270, "负角度要绕回 0..360");
    }

    /// 空列表里没有「第 1 张」这回事 —— 工具条每帧都画 `position()`,
    /// 返回 `(1, 0)` 会让刚进这一页的用户看到一个不存在的序号。
    #[test]
    fn position_is_zero_when_no_files() {
        assert_eq!(ImageViewer::default().position(), (0, 0));
    }

    /// 有图的时候仍然是「从 1 数起」。
    #[test]
    fn position_is_one_based_with_files() {
        let dir = temp_dir("position");
        let a = dir.join("a.png");
        let b = dir.join("b.png");
        std::fs::write(&a, b"x").unwrap();
        std::fs::write(&b, b"x").unwrap();

        let ctx = egui::Context::default();
        let mut v = ImageViewer::default();
        v.load_async(&ctx, &a);
        assert_eq!(v.position(), (1, 2));
        v.load_async(&ctx, &b);
        assert_eq!(v.position(), (2, 2));
        let _ = std::fs::remove_dir_all(&dir);
    }
}