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(¬_a_dir, b"x").unwrap();
let err = scan_images(¬_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);
}
}