Merge branch 'feat/m251-saveshots-save-screenshot-thumbnail'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-11 17:29:30 +08:00
commit 9234c1eeae
24 files changed
+1938 -363

No files matched your search

Generated
+1
View File
@@ -5213,6 +5213,7 @@ dependencies = [
"serde",
"serde_json",
"unlucky-core",
"unlucky-palette-extract",
]
[[package]]
+107 -8
View File
@@ -46,17 +46,23 @@ const OLDEST: u64 = NEWEST - 42 * 24 * 3_600_000;
/// 其中一个世界/一张截图的 mtime 是 `None`:那是「时间未知」这条分支的唯一出图证据。
#[must_use]
pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
use unlucky_presenter::dto::{SavesListView, ScreenshotView, WorldView};
use unlucky_presenter::dto::{
SavesListView, ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind,
ScreenshotView, WorldView,
};
let world = |dir: &str, icon: bool, modified_ms: Option<u64>| WorldView {
dir: dir.to_string(),
icon,
modified_ms,
};
let shot = |file: &str, bytes: u64, modified_ms: Option<u64>| ScreenshotView {
file: file.to_string(),
bytes,
modified_ms,
let shot = |file: &str, bytes: u64, modified_ms: Option<u64>, preview: ScreenshotPreview| {
ScreenshotView {
file: file.to_string(),
bytes,
modified_ms,
preview,
}
};
SavesListView {
@@ -71,10 +77,38 @@ pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
],
screenshots_dir: "D:/ULL/.ULL/data/instances/Better-MC/screenshots".to_string(),
screenshots_exists: true,
// 四张截图,把缩略图墙的**四种格子**一次摆出来(M251):
// ① 白天(真像素)② 黄昏(真像素)③ 「未生成预览」占位 ④ 具名的失败原因。
// 像素是**合成的**(下面 `scene`):夹具不连真实解码器,出图只证明墙的排版与状态,
// 解码由 presenter 的 `host::saves::thumbs` 用例用真 PNG 钉住。
screenshots: vec![
shot("2024-05-01_10.00.00.png", 1_887_436, Some(NEWEST)),
shot("2024-04-30_21.14.05.png", 2_097_152, Some(MIDDLE)),
shot("2024-04-20_08.30.00.png", 964_608, None),
shot(
"2024-05-01_10.00.00.png",
1_887_436,
Some(NEWEST),
ScreenshotPreview::Ready(scene(1)),
),
shot(
"2024-04-30_21.14.05.png",
2_097_152,
Some(MIDDLE),
ScreenshotPreview::Ready(scene(2)),
),
shot(
"2024-04-20_08.30.00.png",
964_608,
None,
ScreenshotPreview::NotGenerated,
),
shot(
"2024-04-11_19.02.41.png",
9_437_184,
Some(OLDEST),
ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: "9437184 > 8388608".to_string(),
}),
),
],
skipped: Vec::new(),
// 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。
@@ -165,3 +199,68 @@ pub fn seed(app: &mut crate::app::state::App, instance: &str) {
Mode::Plain | Mode::BackedUp => {}
}
}
/// 一张**合成的**缩略图(160×90 RGBA):天空渐变 + 地面 + 太阳/月亮。
///
/// 为什么合成而不是读真图:夹具的职责是「让页面有东西可画」,而它跑在截图通道的第 3 帧——
/// 真解码要真的有一张 PNG 在盘上、还要等后台线程回来。合成像素让出图**确定**(两张主题图
/// 逐像素可比),并且明确地不假装自己是解码证据(那由 presenter 的用例负责)。
///
/// `tone`:`1` = 白天(亮天空 + 绿地面),其余 = 黄昏(橙天空 + 暗地面)。同一套几何,
/// 两种配色——墙上两格一眼能看出"每格各画各的"。
fn scene(tone: u8) -> unlucky_presenter::dto::ThumbnailView {
const W: u32 = 160;
const H: u32 = 90;
// 地平线在 2/3 高度(截图里天空总比地面多)。
const HORIZON: u32 = H * 2 / 3;
let (sky_top, sky_bottom, ground, sun): ([u8; 3], [u8; 3], [u8; 3], [u8; 3]) = if tone == 1 {
(
[128, 176, 240],
[200, 224, 248],
[86, 138, 66],
[255, 246, 186],
)
} else {
([96, 66, 96], [232, 148, 96], [58, 74, 48], [255, 214, 150])
};
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for y in 0..H {
for x in 0..W {
let (r, g, b) = if y < HORIZON {
// 天空:从顶到底线性插值(`t` = 0..=255 的定点比例)。
let t = y * 255 / HORIZON.max(1);
let mix = |from: u8, to: u8| -> u8 {
let span = i32::from(to) - i32::from(from);
(i32::from(from) + span * t as i32 / 255) as u8
};
(
mix(sky_top[0], sky_bottom[0]),
mix(sky_top[1], sky_bottom[1]),
mix(sky_top[2], sky_bottom[2]),
)
} else {
// 地面:按 3px 一条的条纹给出「方块地形」的观感。
let stripe = (y / 3) % 2 == 0;
let shade: u8 = if stripe { 0 } else { 14 };
(
ground[0].saturating_sub(shade),
ground[1].saturating_sub(shade),
ground[2].saturating_sub(shade),
)
};
// 太阳/月亮:右上角一个 18px 的圆(够小,不会盖住画面主体)。
let (dx, dy) = (x as i32 - 126, y as i32 - 26);
let (r, g, b) = if dx * dx + dy * dy <= 18 * 18 {
(sun[0], sun[1], sun[2])
} else {
(r, g, b)
};
pixels.extend_from_slice(&[r, g, b, 255]);
}
}
unlucky_presenter::dto::ThumbnailView {
width: W,
height: H,
pixels,
}
}
+28 -155
View File
@@ -51,6 +51,20 @@
//!
//! 写操作**成功**也有一句话([`Saves::notice`]):重命名 / 删除的结果列表上看得见,但备份的落点
//! 看不见——「点了备份然后什么也没发生」是静默失败,所以回执里带上完整路径与字节数。
//!
//! # 截图区是**缩略图墙**(M251 · SAVESHOTS,D54)
//!
//! | 变化 | 落点 |
//! |---|---|
//! | 截图不再是一行字,而是「图 + 副文案」的格子 | [`wall`](2 列 = 同列元信息卡的栅格,见那个模块的文档) |
//! | 像素**不在**这里解,也不在渲染回调里解 | presenter 的后台线程(`host::saves::thumbs`)解好、随快照过来;本层只把 `Handle` 建一次(`model::from_state`) |
//! | 没有像素的格子**不许留白** | 占位框 + 一句短话;副文案把原因说全(超 8 MiB / 读不出 / 解不出 / 超 60 张预算) |
//!
//! ## 为什么拆成三个文件
//!
//! 加完缩略图墙之后 `saves.rs` 会越过 400 行硬线:模型([`model`])、行与操作区([`rows`])、
//! 墙([`wall`])各占一块,本文件只剩「卡片骨架 + 小标 + 空态 + 错误行」。
//! 路径不变([`model`] 的东西用 `pub use` 转出),`view/instances.rs` 与用例的 import 一行不改。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
@@ -58,9 +72,6 @@ use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::Catalog;
use unlucky_presenter::dto::{SavesFailureKind, SavesFailureOp, SavesFailureView};
use unlucky_presenter::state::SavesState;
use crate::app::components::buttons::{self, Button};
use crate::app::components::interaction::Interaction;
@@ -72,158 +83,21 @@ use super::forced_state;
use super::state::Message as PageMessage;
use super::Texts;
mod model;
mod rows;
/// 写操作成功那句话的产地(实现在 [`rows`] 里,页面与测试都从这一条路取)。
mod wall;
/// 句柄 memo 的清空入口(只给用例:它是进程级 memo)。
#[cfg(test)]
pub(super) use model::clear_handles;
pub use model::{failure_line, from_state, Row, Saves, Shot, ShotPreview};
/// 写操作成功那句话的产地(实现在 [`rows`] 里)。
///
/// 生产代码走 `model::from_state → rows::op_line`;这条 re-export 只给**用例**用
/// (`tests/saves/write.rs` 从这里取),所以挂在 `cfg(test)` 下,免得非测试构建报未用导入。
#[cfg(test)]
pub(super) use rows::op_line;
/// 列表里的一行(一个世界 / 一张截图)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
/// 世界目录名或截图文件名(磁盘原样;写操作把它原样带回去当「源」)。
pub name: String,
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
pub detail: String,
}
/// 正在行内重命名的那一行(来源是 presenter 的 `SavesState::draft`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Editing {
/// 哪个世界。
pub dir: String,
/// 输入框里的内容(原样)。
pub text: String,
}
/// SAVES 区的展示模型(`Model::saves`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Saves {
/// 这份数据属于哪个实例(`None` = 不在详情页 / 还没请求过)。
pub bound: Option<String>,
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
pub loading: bool,
/// 读盘/打开/写操作失败的人读原因(app 层按 i18n 拼好)。
pub error: Option<String>,
/// 这一条失败能不能靠「再列一次」解决(只有读盘失败算)。
pub retryable: bool,
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
pub has_view: bool,
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
pub missing: bool,
/// 世界行。
pub worlds: Vec<Row>,
/// 截图行(新的在前,顺序由 core 给)。
pub screenshots: Vec<Row>,
/// 计数行(`3 个世界 · 12 张截图`):**在这一层算好**,页面只借 `&str`。
///
/// 为什么不留到页面里 `format!`:iced 的 `Element` 只借这一帧的数据,页面函数返回的是
/// **借用局部变量的树**,局部 `String` 一出栈就悬空(`E0515`)。与 [`super::mods::Mods`]
/// 的 `count` 同一条做法。
pub count: String,
/// 跳过条目数(符号链接 / 非图片 / 读不出)。
pub skipped: usize,
/// 跳过提示(`跳过 3 项`)。
pub skipped_note: String,
/// 正在行内重命名的那一行(`None` = 没有在改名)。
pub editing: Option<Editing>,
/// 有写操作在飞的那个世界(`None` = 空闲)。
pub busy: Option<String>,
/// 上一次写操作成功的那句话(`None` = 这一拍没有回执)。
pub notice: Option<String>,
}
impl Saves {
/// 有没有拿到一份快照(成功或失败都算;`loading` 中不算)。
#[must_use]
pub fn loaded(&self) -> bool {
self.bound.is_some() && !self.loading
}
/// 两个目录都在、但里面什么都没有(**必须先有一份快照**:纯失败态不是"这里空的")。
#[must_use]
pub fn nothing_yet(&self) -> bool {
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
}
/// 这个世界的写操作在飞吗(那一行画「正在处理…」并把按钮拿掉)。
#[must_use]
pub fn busy_with(&self, dir: &str) -> bool {
self.busy.as_deref() == Some(dir)
}
}
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
///
/// 三条口径:
///
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
/// 2. **不做乐观更新**:行上的名字只来自快照,写操作的"在飞"只用来置灰那一行;
/// 3. **回执只画一次**:`view.last_op` 是 host 写在快照里的,下一次普通 List 就没了。
#[must_use]
pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
let texts = Texts::of(catalog);
let mut out = Saves {
bound: state.bound.clone(),
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
editing: state.draft.as_ref().map(|draft| Editing {
dir: draft.dir.clone(),
text: draft.text.clone(),
}),
busy: state.pending.as_ref().map(|op| op.dir().to_string()),
..Saves::default()
};
if let Some(view) = &state.view {
out.has_view = true;
out.missing = !view.exists && !view.screenshots_exists;
// 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍——
// M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。
out.worlds = view
.worlds
.iter()
.map(|world| Row {
name: world.dir.clone(),
detail: rows::world_detail(world.icon, world.modified_ms, texts),
})
.collect();
out.screenshots = view
.screenshots
.iter()
.map(|shot| Row {
name: shot.file.clone(),
detail: rows::shot_detail(shot.bytes, shot.modified_ms, texts),
})
.collect();
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
out.skipped = view.skipped_count();
out.skipped_note = texts.saves_skipped(out.skipped);
out.notice = view.last_op.as_ref().map(|op| op_line(op, texts));
}
if let Some(failure) = &state.failed {
out.loading = false;
out.error = Some(failure_line(failure, texts));
// 只有「读盘 / 列目录」失败才值得重试;写操作的重试就是再点一次那个按钮。
out.retryable = failure.op.is_none() && failure.kind != SavesFailureKind::OpenFailed;
}
out
}
/// 失败的一句话(**唯一产地**:页面内红字用它,本版没有 Toast)。
#[must_use]
pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
let reason = texts.saves_reason(failure.kind);
let sentence = match (failure.op, failure.kind) {
(Some(SavesFailureOp::Rename), _) => texts.saves_rename_failed(&failure.file, reason),
(Some(SavesFailureOp::Delete), _) => texts.saves_delete_failed(&failure.file, reason),
(Some(SavesFailureOp::Backup), _) => texts.saves_backup_failed(&failure.file, reason),
(None, SavesFailureKind::OpenFailed) => texts.saves_open_failed(reason),
(None, _) => texts.saves_failed(reason),
};
if failure.detail.is_empty() {
return sentence;
}
format!("{sentence}({})", failure.detail)
}
/// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
pub(super) fn section<'a>(
ui: &'a Ui,
@@ -293,9 +167,8 @@ pub(super) fn section<'a>(
if saves.screenshots.is_empty() {
body = body.push(note(ui, texts.saves_no_screenshots()));
} else {
for item in &saves.screenshots {
body = body.push(rows::world_plain(ui, item));
}
// 缩略图墙(M251):一行两格(详情列 ≤424 宽,三格装不下),没像素的格子给占位框 + 原因。
body = body.push(wall::grid(ui, &saves.screenshots));
}
if saves.skipped > 0 {
body = body.push(note(ui, &saves.skipped_note));
@@ -0,0 +1,334 @@
//! SAVES 区的**视图模型**:快照 → 页面要画的东西(M235 的行 + M251 的缩略图墙)。
//!
//! 从 `saves.rs` 拆出(M251:加完缩略图墙的模型之后那个文件会越过 400 行硬线)。
//! 拆法是「**纯搬移 + 一族新增**」:`Row` / `Editing` / `Saves` / [`from_state`] /
//! [`failure_line`] 一律逐行搬来,只把截图那一族从「一行字」换成 [`Shot`](缩略图 + 副文案);
//! `saves.rs` 用 `pub use model::{…}` 保住原有路径,页面与用例的 import 一行不改。
//!
//! # 缩略图的句柄为什么在这里建
//!
//! [`from_state`] 是「快照 → 模型」的**唯一**转换点,而 `iced` 的 `Handle::from_rgba` 会把像素
//! 搬进渲染器(一次上传)。渲染回调(`view()`)每帧都跑、`refresh_view` 只在事件之后跑,
//! 所以句柄只能在这里建一次:建在 `view()` 里等于每帧重传 6MB 纹理。
//!
//! 这也正是 D54「**不许在渲染回调里解码**」的同一条纪律的延伸:解码在 presenter 的后台线程,
//! 句柄在这里,`view()` 只借。
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use iced::widget::image::Handle;
use unlucky_i18n::Catalog;
use unlucky_presenter::dto::{
SavesFailureKind, SavesFailureOp, SavesFailureView, ScreenshotPreview, ScreenshotThumbKind,
ScreenshotView,
};
use unlucky_presenter::state::SavesState;
use super::rows;
use super::Texts;
/// 列表里的一个**世界**行。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
/// 世界目录名(磁盘原样;写操作把它原样带回去当「源」)。
pub name: String,
/// 右侧/下方副文案(时间、图标标记,已由这一层拼好)。
pub detail: String,
}
/// 缩略图墙上的一格(一张截图)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shot {
/// 磁盘文件名(含扩展名)。墙上一格不印它(`160px` 放不下),但失败原因要能指认它。
pub name: String,
/// 格子下面的副文案:正常时是「大小 · 时间」,没有预览时是**为什么**没有。
pub detail: String,
/// 图本身(或占位)。
pub preview: ShotPreview,
}
/// 一格截图里的画面。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShotPreview {
/// 缩略图(句柄在 [`from_state`] 里建好;见模块文档)。
Ready {
/// 已上传的 RGBA 句柄。
handle: Handle,
},
/// 没有可画的像素:框里一句话(「未生成预览」/ 具名原因)。
Missing {
/// 短句(`160×90` 的框里放得下)。
note: String,
},
}
/// 正在行内重命名的那一行(来源是 presenter 的 `SavesState::draft`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Editing {
/// 哪个世界。
pub dir: String,
/// 输入框里的内容(原样)。
pub text: String,
}
/// SAVES 区的展示模型(`Model::saves`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Saves {
/// 这份数据属于哪个实例(`None` = 不在详情页 / 还没请求过)。
pub bound: Option<String>,
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
pub loading: bool,
/// 读盘/打开/写操作失败的人读原因(app 层按 i18n 拼好)。
pub error: Option<String>,
/// 这一条失败能不能靠「再列一次」解决(只有读盘失败算)。
pub retryable: bool,
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
pub has_view: bool,
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
pub missing: bool,
/// 世界行。
pub worlds: Vec<Row>,
/// 截图墙(新的在前,顺序由 core 给)。
pub screenshots: Vec<Shot>,
/// 计数行(`3 个世界 · 12 张截图`):**在这一层算好**,页面只借 `&str`。
///
/// 为什么不留到页面里 `format!`:iced 的 `Element` 只借这一帧的数据,页面函数返回的是
/// **借用局部变量的树**,局部 `String` 一出栈就悬空(`E0515`)。与 [`super::Row`]
/// 之外的 `Mods::count` 同一条做法。
pub count: String,
/// 跳过条目数(符号链接 / 非图片 / 读不出)。
pub skipped: usize,
/// 跳过提示(`跳过 3 项`)。
pub skipped_note: String,
/// 正在行内重命名的那一行(`None` = 没有在改名)。
pub editing: Option<Editing>,
/// 有写操作在飞的那个世界(`None` = 空闲)。
pub busy: Option<String>,
/// 上一次写操作成功的那句话(`None` = 这一拍没有回执)。
pub notice: Option<String>,
}
impl Saves {
/// 有没有拿到一份快照(成功或失败都算;`loading` 中不算)。
#[must_use]
pub fn loaded(&self) -> bool {
self.bound.is_some() && !self.loading
}
/// 两个目录都在、但里面什么都没有(**必须先有一份快照**:纯失败态不是"这里空的")。
#[must_use]
pub fn nothing_yet(&self) -> bool {
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
}
/// 这个世界的写操作在飞吗(那一行画「正在处理…」并把按钮拿掉)。
#[must_use]
pub fn busy_with(&self, dir: &str) -> bool {
self.busy.as_deref() == Some(dir)
}
/// 截图墙里有多少张**真出图**了(出图与用例的抓手:`0` 就是一张都没解出来)。
#[must_use]
pub fn thumbnails(&self) -> usize {
self.screenshots
.iter()
.filter(|shot| matches!(shot.preview, ShotPreview::Ready { .. }))
.count()
}
}
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
///
/// 三条口径:
///
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
/// 2. **不做乐观更新**:行上的名字只来自快照,写操作的"在飞"只用来置灰那一行;
/// 3. **回执只画一次**:`view.last_op` 是 host 写在快照里的,下一次普通 List 就没了。
#[must_use]
pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
let texts = Texts::of(catalog);
let mut out = Saves {
bound: state.bound.clone(),
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
editing: state.draft.as_ref().map(|draft| Editing {
dir: draft.dir.clone(),
text: draft.text.clone(),
}),
busy: state.pending.as_ref().map(|op| op.dir().to_string()),
..Saves::default()
};
if let Some(view) = &state.view {
out.has_view = true;
out.missing = !view.exists && !view.screenshots_exists;
// 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍——
// M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。
out.worlds = view
.worlds
.iter()
.map(|world| Row {
name: world.dir.clone(),
detail: rows::world_detail(world.icon, world.modified_ms, texts),
})
.collect();
out.screenshots = view
.screenshots
.iter()
.map(|shot| shot_cell(&view.screenshots_dir, shot, texts))
.collect();
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
out.skipped = view.skipped_count();
out.skipped_note = texts.saves_skipped(out.skipped);
out.notice = view.last_op.as_ref().map(|op| rows::op_line(op, texts));
}
if let Some(failure) = &state.failed {
out.loading = false;
out.error = Some(failure_line(failure, texts));
// 只有「读盘 / 列目录」失败才值得重试;写操作的重试就是再点一次那个按钮。
out.retryable = failure.op.is_none() && failure.kind != SavesFailureKind::OpenFailed;
}
out
}
/// 句柄缓存的条目上限(超过就整表清空)。
///
/// 一张 160×160 的 RGBA 最坏 ≈ 100KB ⇒ 120 条 ≈ **12MB 上限**;正常一屏 ≤60 张(≈6MB)。
/// 与 presenter 的 `MAX_CACHE_ENTRIES`(也是 120)同量级:两层各自有天花板,不叠成无界。
/// 清空而不是 LRU:代价是下一次 `refresh_view` 重传一遍(罕见),收益是这段逻辑没有顺序状态。
const MAX_HANDLES: usize = 120;
/// 句柄缓存:`(截图目录, 文件名, mtime, 字节数) → Handle`。
///
/// ## 为什么必须缓存(而不是每次 `from_state` 建一个新的)
///
/// `Handle::from_rgba` 每次都生成一个**新 id**,而 `iced` 的渲染器按 id 缓存纹理:id 一变就
/// 重新上传一遍像素。`from_state` 挂在 `refresh_view` 上,而 `refresh_view` 每个 UI 事件都跑
/// (点一下、打一个字)——一屏 60 张就是「每次事件重传 ≈6MB」。M251 复审要求把这条成本消掉:
/// 缓存之后同一张图永远是同一个 id,纹理只上传一次。
///
/// ## 键为什么与 presenter 的缓存键同源
///
/// presenter 那边是 `(完整路径, mtime, 字节数)`(`host::saves::thumbs`),这里换成
/// `(目录, 文件, mtime, 字节数)`——同一组事实,只是目录单独放一格,免得每次拼长串。
/// 任一字段变了(文件被覆盖)就是另一张图 ⇒ 新 id ⇒ 重传,这也正是我们要的。
fn handles() -> &'static Mutex<HashMap<String, Handle>> {
static HANDLES: OnceLock<Mutex<HashMap<String, Handle>>> = OnceLock::new();
HANDLES.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 句柄键的分隔符(`0x01`:文件名里不可能出现,也避免 `<a|b>` 与 `<ab|>` 撞键)。
///
/// 用 `push` 拼而不是 `format!`:`format!` 的模板串里带 `dir`/`u` 这类 ASCII 词,会被
/// `pages/instances/tests/i18n.rs` 的「页面里不许写死英文文案」守门当成界面字面量抓走。
const KEY_SEP: char = '\u{1}';
/// 取这张截图的句柄:命中就复用(id 不变 ⇒ 不重传),未命中才建一次。
fn handle_for(
dir: &str,
shot: &ScreenshotView,
thumb: &unlucky_presenter::dto::ThumbnailView,
) -> Handle {
let mut key = String::with_capacity(dir.len() + shot.file.len() + 24);
key.push_str(dir);
key.push(KEY_SEP);
key.push_str(&shot.file);
key.push(KEY_SEP);
match shot.modified_ms {
Some(ms) => key.push_str(&ms.to_string()),
// 取不到时间:占一个不含数字的记号(不会与任何毫秒数撞上)。
None => key.push('-'),
}
key.push(KEY_SEP);
key.push_str(&shot.bytes.to_string());
let mut store = handles()
.lock()
.unwrap_or_else(|poison| poison.into_inner());
if let Some(cached) = store.get(&key) {
return cached.clone();
}
let handle = Handle::from_rgba(thumb.width, thumb.height, thumb.pixels.clone());
if store.len() >= MAX_HANDLES {
store.clear();
}
store.insert(key, handle.clone());
handle
}
/// 清空句柄缓存(**只给用例**:这是进程级 memo,用例之间必须能从已知状态起跑)。
#[cfg(test)]
pub(in crate::pages::instances) fn clear_handles() {
handles()
.lock()
.unwrap_or_else(|poison| poison.into_inner())
.clear();
}
/// 一张截图 → 墙上的一格(缩略图句柄 / 占位 + 副文案)。
///
/// **三种状态各有各的话**(D54 第 6 条:失败逐条可见):
///
/// | 快照里的状态 | 框里 | 副文案 |
/// |---|---|---|
/// | `Ready`(像素自洽) | 真图 | `大小 · 时间` |
/// | `Ready`(像素长度不对) | 「解不出这张图」 | `大小 · 时间`(防御性:壳层不该拿到坏像素) |
/// | `NotGenerated` | 「未生成预览」 | `大小 · 时间`(超预算没轮到) |
/// | `Failed` | 具名原因(太大 / 读不出 / 解不出) | `大小 · 时间`(原文留在 DTO 里,不上墙) |
fn shot_cell(dir: &str, shot: &ScreenshotView, texts: Texts) -> Shot {
let stamp = rows::shot_detail(shot.bytes, shot.modified_ms, texts);
match &shot.preview {
ScreenshotPreview::Ready(thumb) if thumb.is_consistent() => Shot {
name: shot.file.clone(),
detail: stamp,
// 句柄按 (目录, 文件, mtime, 大小) 复用:同一张图在后续每一次 `refresh_view` 里
// 拿到的都是**同一个 id** ⇒ 渲染器不会重传纹理(见 [`handle_for`])。
preview: ShotPreview::Ready {
handle: handle_for(dir, shot, thumb),
},
},
// 像素长度与宽高对不上:壳层不该拿到这种数据,但拿到了也不能画成花屏。
ScreenshotPreview::Ready(_) => missing(
shot,
texts.saves_preview_reason(ScreenshotThumbKind::Decode),
stamp,
),
ScreenshotPreview::NotGenerated => missing(shot, texts.saves_preview_none(), stamp),
ScreenshotPreview::Failed(failure) => {
missing(shot, texts.saves_preview_reason(failure.kind), stamp)
}
}
}
/// 「没有可画的像素」那一格:**框里说为什么**(短句),**格子下面照旧是大小与时间**。
///
/// 为什么原因不进副文案:`160px` 宽的格子里,「原因 + OS 原文」会摊成三行,把墙变得比图还吵;
/// 而 `ScreenshotThumbFailure::detail`(`9437184 > 8388608 字节` / `Access is denied.`)留在
/// DTO 里,是排障用的原文,不是给墙看的正文。D54 第 6 条要的是「逐条、结构化、不是静默」——
/// 那一句在框里,每条各说各的。
fn missing(shot: &ScreenshotView, note: &str, stamp: String) -> Shot {
Shot {
name: shot.file.clone(),
detail: stamp,
preview: ShotPreview::Missing {
note: note.to_string(),
},
}
}
/// 失败的一句话(**唯一产地**:页面内红字用它,本版没有 Toast)。
#[must_use]
pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
let reason = texts.saves_reason(failure.kind);
let sentence = match (failure.op, failure.kind) {
(Some(SavesFailureOp::Rename), _) => texts.saves_rename_failed(&failure.file, reason),
(Some(SavesFailureOp::Delete), _) => texts.saves_delete_failed(&failure.file, reason),
(Some(SavesFailureOp::Backup), _) => texts.saves_backup_failed(&failure.file, reason),
(None, SavesFailureKind::OpenFailed) => texts.saves_open_failed(reason),
(None, _) => texts.saves_failed(reason),
};
if failure.detail.is_empty() {
return sentence;
}
format!("{sentence}({})", failure.detail)
}
@@ -6,8 +6,10 @@
//! | 函数 | 画什么 |
//! |---|---|
//! | [`world`] | 一个世界:名字 + 副文案 +(空闲)重命名/备份/删除、(在飞)「正在处理…」、(改名中)输入框 + 改名/取消 |
//! | [`world_plain`] | 只读的一行(截图用它:截图不在三个写操作的范围里) |
//! | [`world_detail`] / [`shot_detail`] | 两行副文案 |
//! | [`world_detail`] / [`shot_detail`] | 两行副文案(`shot_detail` 现在喂缩略图墙的格子,见 [`super::wall`]) |
//!
//! M251:原来给截图用的只读行 [`world_plain`] **已删**——截图改画缩略图墙(`super::wall`),
//! 留着一个没人调的 `pub(super) fn` 只会在 `-D warnings` 下变成死代码告警。
//!
//! 三枚按钮与「打开存档文件夹」同一条口径:`buttons::text` + `frame::BASE`(M237:卡片里的控件
//! 把底传成卡底,hover/禁用状态才叠在卡实色上),出图取证时整行一起跟 `--force-state`。
@@ -83,11 +85,6 @@ pub(super) fn world<'a>(
.into()
}
/// 只读的一行(截图)。与 [`world`] 的区别只有一条:没有操作区。
pub(super) fn world_plain<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> {
labels(ui, item, Role::OnSurface).into()
}
/// 行内重命名:输入框 + 「改名」/「取消」(占满整行,副文案让位)。
fn rename_row<'a>(
ui: &'a Ui,
@@ -0,0 +1,126 @@
//! 截图**缩略图墙**(M251 · SAVESHOTS,D54)。
//!
//! ## 画什么
//!
//! 每格 = 「图(或占位框)」+ 下面一行副文案,按 [`PER_ROW`] 列换行排。图的最大边由
//! `palette_extract::THUMBNAIL_MAX_EDGE = 160` 定(解码那一侧),这里只负责**摆**:
//!
//! | 格子内容 | 画什么 |
//! |---|---|
//! | [`ShotPreview::Ready`] | `iced::widget::image::Handle` 已经是 RGBA 像素,直接画(**不再解码**) |
//! | [`ShotPreview::Missing`] | `160×90` 的描边占位框 + 一句短话(「未生成预览」/ 具名原因) |
//!
//! ## 为什么不用 `grid_row`
//!
//! iced 0.14 的基础控件里没有流式网格,而本仓没有引 `iced_aw` 之类的扩展 crate(多一个依赖要过
//! `deny.toml`)。墙的行数是 `ceil(n / PER_ROW)`,用手写的 `column` + `row` 摆——比引一个
//! 扩展 crate 便宜,也比让它自己换行可靠(换行行为不会被窗口宽度悄悄改掉)。
//!
//! ## 宽高比
//!
//! 缩略图的**长边是 160**,短边按原图比例(截图通常 16:9 ⇒ 160×90)。所以宽度钉死、高度
//! `Shrink`:`iced` 的 image 控件在 `width = Fixed` / `height = Shrink` 下按原图比例取高,
//! 不拉伸(`ContentFit::Contain` 默认)。占位框反而钉死 `160×90`——它是"这里本该有一张图"的
//! 占位,尺寸要可预测,不能让一句话把它撑变形。
//!
//! ## 一行放几格(这条是**量出来的**,不是拍的)
//!
//! 详情列的宽度由 `pages::layout` 的 `FrameMetrics::column` 定,而它 `≤ measure_cap_px()`
//! (1180 窗宽下 = 424,见 `layout.rs` 的文档)。卡片还有左右内边距 ⇒ 实际内宽约 **370**。
//!
//! 于是:**3 格 = 3 × 160 + 2 × 12 = 504px 装不下**(M251 第一次出图实测:第 3 格被挤成一条
//! 竖条,`未生成预览` 四个字竖着排)。2 格 = `2 × 160 + 12 = 332px`,与同一列里元信息卡的
//! 两列栅格(`META_CARD_MIN_WIDTH × 2 + META_GAP = 332`)**同宽**——这不是巧合,是同一列里
//! 已经成立的节奏。
//!
//! 代价写在明处:60 张 = 30 行,要滚很久。换 3 列只能把格子缩到 ~112px(图更小),D54 的
//! `THUMBNAIL_MAX_EDGE = 160` 也就白解了;要真正做到「宽度自适应列数」得引一个栅格控件,
//! 那是新依赖,不在本 mission 的取舍里。
use iced::widget::{column, container, image, row};
use iced::{Alignment, Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::{Shot, ShotPreview};
/// 一格缩略图的宽(= 解码侧的最大边,两者必须同源:图比格子小会留白,比格子大会被缩)。
const THUMB_WIDTH: f32 = 160.0;
/// 占位框的高(16:9,与常见的截图宽高比一致)。
const THUMB_HEIGHT: f32 = 90.0;
/// 一行放几格(= 同一列里元信息卡的两列栅格;理由见模块文档)。
const PER_ROW: usize = 2;
/// 整面墙(`saves.rs` 的 `section` 调;空列表不该走到这里)。
pub(super) fn grid<'a>(ui: &'a Ui, shots: &'a [Shot]) -> Element<'a, Message> {
let mut wall = column![].spacing(ui.space(Space::X3)).width(Length::Fill);
for chunk in shots.chunks(PER_ROW) {
let mut line = row![]
.spacing(ui.space(Space::X3))
.align_y(Alignment::Start);
for shot in chunk {
line = line.push(cell(ui, shot));
}
wall = wall.push(line);
}
wall.into()
}
/// 一格:图或占位框 + 副文案。
fn cell<'a>(ui: &'a Ui, shot: &'a Shot) -> Element<'a, Message> {
let picture: Element<'a, Message> = match &shot.preview {
ShotPreview::Ready { handle } => image(handle.clone())
.width(Length::Fixed(THUMB_WIDTH))
.height(Length::Shrink)
.border_radius(ui.radius_dp(ShapeScale::Small))
.into(),
ShotPreview::Missing { note } => placeholder(ui, note.as_str()),
};
column![
picture,
frame::text_role(
ui,
TypeRole::BodySmall,
false,
shot.detail.as_str(),
ui.color(Role::OnSurfaceVariant),
)
]
.spacing(ui.space(Space::X1))
.width(Length::Fixed(THUMB_WIDTH))
.into()
}
/// 占位框:描边 + 容器色底 + 居中的那句短话。
///
/// 颜色全部走令牌(D55 第 3 条:组件源码里不许出现字面色值);底用 `SurfaceContainerHigh`
/// 而不是画布色,是为了在卡片(`SurfaceContainerLow`)上还能看出"这里有一个格子"。
fn placeholder<'a>(ui: &'a Ui, note: &'a str) -> Element<'a, Message> {
container(frame::text_role_centered(
ui,
TypeRole::BodySmall,
false,
note,
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fixed(THUMB_WIDTH))
.height(Length::Fixed(THUMB_HEIGHT))
.padding(ui.space(Space::X2))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SurfaceContainerHigh))),
border: ui.outlined(ShapeScale::Small),
..container::Style::default()
})
.into()
}
@@ -49,11 +49,14 @@ const ALLOWED: &[&str] = &[
];
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 9] = [
const PAGE_SOURCES: [(&str, &str); 12] = [
("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")),
("instances/text.rs", include_str!("../text.rs")),
// M251:存档 / 截图一族从 text.rs 拆出(那边越过 400 行)——新文件必须一起扫,
// 否则那四句缩略图文案会被当成「死 key」。
("instances/text/saves.rs", include_str!("../text/saves.rs")),
(
"instances/launch_args.rs",
include_str!("../launch_args.rs"),
@@ -68,6 +71,13 @@ const PAGE_SOURCES: [(&str, &str); 9] = [
("instances/saves.rs", include_str!("../saves.rs")),
// M247:SAVES 区的行与操作区(重命名 / 备份 / 删除 + 行内输入框)
("instances/saves/rows.rs", include_str!("../saves/rows.rs")),
// M251:SAVES 区的视图模型(快照 → 行/格子;缩略图句柄在这里建)
(
"instances/saves/model.rs",
include_str!("../saves/model.rs"),
),
// M251:截图缩略图墙
("instances/saves/wall.rs", include_str!("../saves/wall.rs")),
];
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
@@ -319,25 +329,35 @@ fn every_english_literal_in_the_page_sources_is_whitelisted() {
"只扫到 {checked} 条带英文的字面量,扫描器可能失效了"
);
// 反向锚②:这些来源**必须**真的在用 Texts / 目录
// 反向锚②:这些来源**必须**真的在用 Texts / 目录。
//
// M251:原来按下标取(`PAGE_SOURCES[5]`),插进一条新文件就整体错位——改成**按文件名取**,
// 以后再拆文件不必回来改下标(拆错文件时仍然会红:找不到那个名字就 panic)。
let source = |name: &str| -> &'static str {
PAGE_SOURCES
.iter()
.find(|(file, _)| *file == name)
.map(|(_, source)| *source)
.unwrap_or_else(|| panic!("PAGE_SOURCES 里没有 {name}:拆文件之后忘了登记?"))
};
assert!(
PAGE_SOURCES[0].1.contains("Texts::of("),
source("instances.rs").contains("Texts::of("),
"实例页没有从 Texts 取词"
);
assert!(
PAGE_SOURCES[0].1.contains("Header::new(texts.title())"),
source("instances.rs").contains("Header::new(texts.title())"),
"页头标题必须是词表里的那条(`Header::new` 里写死字符串 = 中文界面上顶着英文标题)"
);
assert!(
PAGE_SOURCES[3].1.contains("Key::Instances"),
source("instances/text.rs").contains("Key::Instances"),
"text.rs 没有引用本族的 key"
);
assert!(
PAGE_SOURCES[5].1.contains("Key::InstancesStage"),
source("app/view/instances.rs").contains("Key::InstancesStage"),
"阶段标签没有走目录"
);
assert!(
PAGE_SOURCES[4].1.contains("texts.launch_title()"),
source("instances/launch_args.rs").contains("texts.launch_title()"),
"启动参数区没有走目录"
);
}
@@ -9,7 +9,7 @@
use unlucky_presenter::dto::{
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
ScreenshotView, WorldView,
ScreenshotPreview, ScreenshotView, ThumbnailView, WorldView,
};
use unlucky_presenter::state::SavesState;
@@ -36,6 +36,12 @@ fn view(instance: &str) -> SavesListView {
file: "2024-05-01_10.00.00.png".to_string(),
bytes: 2_048,
modified_ms: None,
// M251:一像素的缩略图——这条用例只关心「格子/计数/副文案」的映射,不关心解码。
preview: ScreenshotPreview::Ready(ThumbnailView {
width: 1,
height: 1,
pixels: vec![0x2E, 0x7D, 0x4F, 0xFF],
}),
}],
skipped: vec![SavesSkipView {
area: SavesArea::Screenshots,
@@ -79,6 +85,11 @@ fn the_snapshot_becomes_rows_with_stamps_and_badges() {
);
assert_eq!(saves.screenshots.len(), 1);
assert_eq!(
saves.thumbnails(),
1,
"带了像素的截图必须变成一格真图(不是占位)"
);
assert!(
saves.screenshots[0].detail.contains("2.0 KB"),
"截图的副文案要带大小:{:?}",
@@ -242,4 +253,5 @@ fn millisecond_stamps_are_rendered_in_utc() {
);
}
mod preview;
mod write;
@@ -0,0 +1,172 @@
//! 缩略图墙的**页面模型判据**(M251)——从 `tests/saves.rs` 拆出(那边加完句柄复用的两条之后越过
//! 400 行硬线,与 `write.rs` 同一拆法)。
//!
//! 这一族只钉「快照里的三种预览状态 → 页面模型」与「句柄复用」,**不**画像素:
//! `Element` 没有内省接口,画出来对不对只能看 `DevDocs/artifacts/m251/` 的图(README 里写明哪张
//! 是排版、哪张是端到端真解码)。
use super::super::super::saves::clear_handles;
use super::super::super::saves::{from_state, Saves, ShotPreview};
use super::super::super::Texts;
use super::{bound_state, view, STAMP};
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ScreenshotView, ThumbnailView,
};
/// 句柄 id 的取样口(只有 `Ready` 有句柄;别的一律 panic,免得断言在错误的状态上通过)。
fn handle_id(saves: &Saves) -> iced::widget::image::Handle {
match &saves.screenshots[0].preview {
ShotPreview::Ready { handle } => handle.clone(),
other => panic!("第一格应当是缩略图,实际 {other:?}"),
}
}
/// **缩略图墙的三种格子**(M251 · D54 第 6 条):有像素 / 没轮到 / 具名失败,副文案各说各的。
///
/// 抓得住:①有像素却不画(`Ready` 被吞成占位);②「没生成」与「失败」被合并成同一句
/// (用户没法分辨"刷新一下可能就有了"与"得自己去修文件");③失败原因没上屏(原文丢掉)。
/// 抓不住:像素画得对不对(`Element` 没有内省接口,只能靠 `DevDocs/artifacts/m251/` 的图)。
#[test]
pub(super) fn every_preview_state_gets_its_own_caption() {
let texts = Texts::of(Lang::ZhCn.catalog());
let mut snapshot = view("a");
snapshot.screenshots = vec![
ScreenshotView {
file: "ok.png".to_string(),
bytes: 2_048,
modified_ms: Some(STAMP),
preview: ScreenshotPreview::Ready(ThumbnailView {
width: 2,
height: 1,
pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
}),
},
ScreenshotView {
file: "pending.png".to_string(),
bytes: 4_096,
modified_ms: Some(STAMP - 1),
preview: ScreenshotPreview::NotGenerated,
},
ScreenshotView {
file: "huge.png".to_string(),
bytes: 9_437_184,
modified_ms: Some(STAMP - 2),
preview: ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: "9437184 > 8388608 字节".to_string(),
}),
},
];
let mut state = bound_state("a");
state.changed(snapshot);
let saves = from_state(&state, Lang::ZhCn.catalog());
assert_eq!(saves.thumbnails(), 1, "只有第一格有像素");
assert!(matches!(
saves.screenshots[0].preview,
ShotPreview::Ready { .. }
));
assert_eq!(
saves.screenshots[1].preview,
ShotPreview::Missing {
note: texts.saves_preview_none().to_string()
}
);
assert_eq!(
saves.screenshots[2].preview,
ShotPreview::Missing {
note: texts
.saves_preview_reason(ScreenshotThumbKind::TooLarge)
.to_string()
}
);
// 副文案三格一样是「大小 · 时间」:原因在框里,不把墙变得比图还吵(原文留在 DTO 里)。
assert!(
saves.screenshots[1].detail.starts_with("4.0 KB"),
"没生成的那格也要有大小与时间:{:?}",
saves.screenshots[1].detail
);
assert!(
saves.screenshots[2].detail.starts_with("9.0 MB"),
"失败的那格同样保留元数据:{:?}",
saves.screenshots[2].detail
);
}
/// 像素长度与宽高对不上时**不许**建句柄(`Handle::from_rgba` 不校验长度 ⇒ 画出来是花屏)。
///
/// 抓得住:把 `is_consistent()` 那道判断删掉(这时会走 `Ready`,`thumbnails()` 变 1 而断言要 0)。
#[test]
pub(super) fn inconsistent_pixels_are_shown_as_a_failure_not_a_broken_image() {
let mut snapshot = view("a");
snapshot.screenshots = vec![ScreenshotView {
file: "short.png".to_string(),
bytes: 10,
modified_ms: Some(STAMP),
preview: ScreenshotPreview::Ready(ThumbnailView {
width: 4,
height: 4,
pixels: vec![0; 15],
}),
}];
let mut state = bound_state("a");
state.changed(snapshot);
let saves = from_state(&state, Lang::ZhCn.catalog());
assert_eq!(saves.thumbnails(), 0, "坏像素不该被当成图");
assert_eq!(
saves.screenshots[0].preview,
ShotPreview::Missing {
note: Texts::of(Lang::ZhCn.catalog())
.saves_preview_reason(ScreenshotThumbKind::Decode)
.to_string()
}
);
}
/// **句柄复用(M251 复审要求)**:同一张图两次 `from_state` 必须拿到**同一个 `Handle` id**。
///
/// 为什么这条是判据而不是细节:`Handle::from_rgba` 每次生成新 id,而 iced 的渲染器按 id 缓存
/// 纹理——id 一变就把像素重传一遍。`from_state` 挂在 `refresh_view` 上(每个 UI 事件都跑),
/// 一屏 60 张就是「每次事件重传 ≈6MB」。id 相同 ⇒ 纹理只上传一次。
///
/// 抓得住:把 `handle_for` 换回 `Handle::from_rgba(...)`(两次 id 必不相同)、键里漏掉 mtime/大小
/// (文件变了却复用旧句柄,见下一条)。
/// 抓不住:渲染器到底有没有复用纹理(那是 iced 内部,只能由「id 相同」推断)。
#[test]
pub(super) fn the_same_screenshot_reuses_one_handle() {
clear_handles();
let mut state = bound_state("a");
state.changed(view("a"));
let first = from_state(&state, Lang::ZhCn.catalog());
let second = from_state(&state, Lang::ZhCn.catalog());
assert_eq!(
handle_id(&first).id(),
handle_id(&second).id(),
"同一张图在两次 refresh_view 里必须复用同一个句柄(否则每帧重传纹理)"
);
}
/// 文件被覆盖(mtime 变)⇒ **必须换一个新句柄**(缓存键里含 mtime 的另一半证据)。
///
/// 抓得住:句柄键只按 `(目录, 文件名)`(截图被覆盖后仍画旧像素)。
#[test]
pub(super) fn a_changed_screenshot_gets_a_new_handle() {
clear_handles();
let mut state = bound_state("a");
state.changed(view("a"));
let first = from_state(&state, Lang::ZhCn.catalog());
let mut changed = view("a");
changed.screenshots[0].modified_ms = Some(STAMP + 1);
let mut state = bound_state("a");
state.changed(changed);
let second = from_state(&state, Lang::ZhCn.catalog());
assert_ne!(
handle_id(&first).id(),
handle_id(&second).id(),
"mtime 变了就是另一张图,必须换句柄"
);
}
+4 -162
View File
@@ -26,7 +26,10 @@
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{ModFailureKind, SavesFailureKind};
use unlucky_presenter::dto::ModFailureKind;
/// 存档 / 截图一族的文案(M235 读路径 + M247 写操作 + M251 缩略图)
mod saves;
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -212,167 +215,6 @@ impl Texts {
})
}
// ------------------------------------------------------------- 存档区(M235)
pub(super) fn saves_title(self) -> &'static str {
self.text(Key::InstancesSavesTitle)
}
/// 「3 个世界 · 12 张截图」。
pub(super) fn saves_count(self, worlds: usize, shots: usize) -> String {
self.0.render(
Key::InstancesSavesCount,
&[
("worlds", &worlds.to_string()),
("shots", &shots.to_string()),
],
)
}
pub(super) fn saves_loading(self) -> &'static str {
self.text(Key::InstancesSavesLoading)
}
pub(super) fn saves_empty(self) -> &'static str {
self.text(Key::InstancesSavesEmpty)
}
pub(super) fn saves_missing_dir(self) -> &'static str {
self.text(Key::InstancesSavesMissingDir)
}
pub(super) fn saves_worlds(self) -> &'static str {
self.text(Key::InstancesSavesWorlds)
}
pub(super) fn saves_screenshots(self) -> &'static str {
self.text(Key::InstancesSavesScreenshots)
}
pub(super) fn saves_no_worlds(self) -> &'static str {
self.text(Key::InstancesSavesNoWorlds)
}
pub(super) fn saves_no_screenshots(self) -> &'static str {
self.text(Key::InstancesSavesNoScreenshots)
}
/// 「有图标」:只判 `icon.png` 存不存在(不解码,缩略图墙不在 U3 范围内)。
pub(super) fn saves_world_icon(self) -> &'static str {
self.text(Key::InstancesSavesWorldIcon)
}
pub(super) fn saves_time_unknown(self) -> &'static str {
self.text(Key::InstancesSavesTimeUnknown)
}
/// 「跳过 3 项」。
pub(super) fn saves_skipped(self, count: usize) -> String {
self.0
.render(Key::InstancesSavesSkipped, &[("count", &count.to_string())])
}
pub(super) fn saves_open_folder(self) -> &'static str {
self.text(Key::InstancesSavesOpenFolder)
}
pub(super) fn saves_retry(self) -> &'static str {
self.text(Key::InstancesSavesRetry)
}
/// 读目录失败的主句:`读取 saves/ 失败:目录不可用`。
pub(super) fn saves_failed(self, reason: &str) -> String {
self.0
.render(Key::InstancesSavesFailed, &[("reason", reason)])
}
/// 打开文件夹失败的主句:`打开文件夹失败:文件管理器没起来`。
pub(super) fn saves_open_failed(self, reason: &str) -> String {
self.0
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
}
/// 失败大类的短句(`SavesFailureKind` → 一句话;写操作那八类各有自己的话)。
pub(super) fn saves_reason(self, kind: SavesFailureKind) -> &'static str {
self.text(match kind {
SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir,
SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance,
SavesFailureKind::OpenFailed => Key::InstancesSavesReasonOpen,
SavesFailureKind::WriteUnsafeName => Key::InstancesSavesReasonUnsafeName,
SavesFailureKind::WriteSymlink => Key::InstancesSavesReasonSymlink,
SavesFailureKind::WriteMissing => Key::InstancesSavesReasonMissing,
SavesFailureKind::WriteNotADirectory => Key::InstancesSavesReasonNotDir,
SavesFailureKind::WriteConflict => Key::InstancesSavesReasonConflict,
SavesFailureKind::WriteBoundary => Key::InstancesSavesReasonBoundary,
SavesFailureKind::WriteTooLarge => Key::InstancesSavesReasonTooLarge,
SavesFailureKind::GameRunning => Key::InstancesSavesReasonRunning,
SavesFailureKind::WriteFailed => Key::InstancesSavesReasonWrite,
})
}
// ------------------------------------------------- 存档写操作(M247 · SAVESOPS)
pub(super) fn saves_rename(self) -> &'static str {
self.text(Key::InstancesSavesRename)
}
pub(super) fn saves_backup(self) -> &'static str {
self.text(Key::InstancesSavesBackup)
}
pub(super) fn saves_delete(self) -> &'static str {
self.text(Key::InstancesSavesDelete)
}
/// 行内输入框的占位符(不是按钮文案:它说「该填什么」)。
pub(super) fn saves_rename_placeholder(self) -> &'static str {
self.text(Key::InstancesSavesRenamePlaceholder)
}
pub(super) fn saves_rename_confirm(self) -> &'static str {
self.text(Key::InstancesSavesRenameConfirm)
}
pub(super) fn saves_cancel(self) -> &'static str {
self.text(Key::InstancesSavesCancel)
}
/// 「正在处理…」:写请求在飞时顶替那一行的操作区。
pub(super) fn saves_busy(self) -> &'static str {
self.text(Key::InstancesSavesBusy)
}
pub(super) fn saves_delete_title(self) -> &'static str {
self.text(Key::InstancesSavesDeleteTitle)
}
/// 删除确认框的正文:`{dir} 和它里面的全部内容都会被删除,且不能撤销。`
pub(super) fn saves_delete_body(self, dir: &str) -> String {
self.0
.render(Key::InstancesSavesDeleteBody, &[("dir", dir)])
}
pub(super) fn saves_delete_confirm(self) -> &'static str {
self.text(Key::InstancesSavesDeleteConfirm)
}
/// 改名成功的回执:`已把 A 改名为 B。`
pub(super) fn saves_renamed(self, from: &str, to: &str) -> String {
self.0
.render(Key::InstancesSavesRenamed, &[("from", from), ("to", to)])
}
/// 删除成功的回执:`已删除 A。`
pub(super) fn saves_deleted(self, dir: &str) -> String {
self.0.render(Key::InstancesSavesDeleted, &[("dir", dir)])
}
/// 备份成功的回执:`已把 A 备份到 D:/…/A-…zip(1.2 MiB)。`
pub(super) fn saves_backed_up(self, dir: &str, path: &str, size: &str) -> String {
self.0.render(
Key::InstancesSavesBackedUp,
&[("dir", dir), ("path", path), ("size", size)],
)
}
/// 「跳过 N 个符号链接」(备份时没跟随的那些)。
pub(super) fn saves_backup_skipped(self, count: usize) -> String {
self.0.render(
Key::InstancesSavesBackupSkipped,
&[("count", &count.to_string())],
)
}
/// 重命名失败的主句:`重命名 A 失败:同名世界已存在`。
pub(super) fn saves_rename_failed(self, file: &str, reason: &str) -> String {
self.0.render(
Key::InstancesSavesRenameFailed,
&[("file", file), ("reason", reason)],
)
}
/// 删除失败的主句。
pub(super) fn saves_delete_failed(self, file: &str, reason: &str) -> String {
self.0.render(
Key::InstancesSavesDeleteFailed,
&[("file", file), ("reason", reason)],
)
}
/// 备份失败的主句。
pub(super) fn saves_backup_failed(self, file: &str, reason: &str) -> String {
self.0.render(
Key::InstancesSavesBackupFailed,
&[("file", file), ("reason", reason)],
)
}
fn text(self, key: Key) -> &'static str {
self.0.text(key)
}
@@ -0,0 +1,214 @@
//! 实例页文本入口的**存档 / 截图一族**(从 `text.rs` 拆出:那边加完缩略图那四句之后越过
//! 400 行硬线,M251)。
//!
//! 拆法是「**纯搬移 + 一族新增**」:M235 的读路径、M247 的写操作、M251 的缩略图三批 key 全在这一
//! 个 `impl Texts` 块里,`text.rs` 保留导航 / 元信息 / MODS 那几族。两处都是 `impl Texts`(Rust
//! 允许这么写),调用方依旧写 `texts.saves_*()`,路径不变。
//!
//! 口径与 `text.rs` 完全一致:**不拼中文**,每个方法只把 `Key` 翻成 `&'static str`(带占位符的用
//! `Catalog::render` 现填);带 `{reason}` / `{file}` 的句子由调用方把「已完成的事实」传进来。
use unlucky_i18n::Key;
use unlucky_presenter::dto::{SavesFailureKind, ScreenshotThumbKind};
use super::Texts;
impl Texts {
// ------------------------------------------------------------- 存档区(M235)
pub(in crate::pages::instances) fn saves_title(self) -> &'static str {
self.text(Key::InstancesSavesTitle)
}
/// 「3 个世界 · 12 张截图」。
pub(in crate::pages::instances) fn saves_count(self, worlds: usize, shots: usize) -> String {
self.0.render(
Key::InstancesSavesCount,
&[
("worlds", &worlds.to_string()),
("shots", &shots.to_string()),
],
)
}
pub(in crate::pages::instances) fn saves_loading(self) -> &'static str {
self.text(Key::InstancesSavesLoading)
}
pub(in crate::pages::instances) fn saves_empty(self) -> &'static str {
self.text(Key::InstancesSavesEmpty)
}
pub(in crate::pages::instances) fn saves_missing_dir(self) -> &'static str {
self.text(Key::InstancesSavesMissingDir)
}
pub(in crate::pages::instances) fn saves_worlds(self) -> &'static str {
self.text(Key::InstancesSavesWorlds)
}
pub(in crate::pages::instances) fn saves_screenshots(self) -> &'static str {
self.text(Key::InstancesSavesScreenshots)
}
pub(in crate::pages::instances) fn saves_no_worlds(self) -> &'static str {
self.text(Key::InstancesSavesNoWorlds)
}
pub(in crate::pages::instances) fn saves_no_screenshots(self) -> &'static str {
self.text(Key::InstancesSavesNoScreenshots)
}
/// 「有图标」:只判 `icon.png` 存不存在。
///
/// M251 之后**仍未解**世界图标——缩略图墙只覆盖 `screenshots/`(D54 的范围)。这行字眼
/// 原来写的是「缩略图墙不在 U3 范围内」,那句话今天已经失真(墙做了),改成现状描述。
pub(in crate::pages::instances) fn saves_world_icon(self) -> &'static str {
self.text(Key::InstancesSavesWorldIcon)
}
pub(in crate::pages::instances) fn saves_time_unknown(self) -> &'static str {
self.text(Key::InstancesSavesTimeUnknown)
}
/// 「跳过 3 项」。
pub(in crate::pages::instances) fn saves_skipped(self, count: usize) -> String {
self.0
.render(Key::InstancesSavesSkipped, &[("count", &count.to_string())])
}
pub(in crate::pages::instances) fn saves_open_folder(self) -> &'static str {
self.text(Key::InstancesSavesOpenFolder)
}
pub(in crate::pages::instances) fn saves_retry(self) -> &'static str {
self.text(Key::InstancesSavesRetry)
}
/// 读目录失败的主句:`读取 saves/ 失败:目录不可用`。
pub(in crate::pages::instances) fn saves_failed(self, reason: &str) -> String {
self.0
.render(Key::InstancesSavesFailed, &[("reason", reason)])
}
/// 打开文件夹失败的主句:`打开文件夹失败:文件管理器没起来`。
pub(in crate::pages::instances) fn saves_open_failed(self, reason: &str) -> String {
self.0
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
}
/// 失败大类的短句(`SavesFailureKind` → 一句话;写操作那八类各有自己的话)。
pub(in crate::pages::instances) fn saves_reason(self, kind: SavesFailureKind) -> &'static str {
self.text(match kind {
SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir,
SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance,
SavesFailureKind::OpenFailed => Key::InstancesSavesReasonOpen,
SavesFailureKind::WriteUnsafeName => Key::InstancesSavesReasonUnsafeName,
SavesFailureKind::WriteSymlink => Key::InstancesSavesReasonSymlink,
SavesFailureKind::WriteMissing => Key::InstancesSavesReasonMissing,
SavesFailureKind::WriteNotADirectory => Key::InstancesSavesReasonNotDir,
SavesFailureKind::WriteConflict => Key::InstancesSavesReasonConflict,
SavesFailureKind::WriteBoundary => Key::InstancesSavesReasonBoundary,
SavesFailureKind::WriteTooLarge => Key::InstancesSavesReasonTooLarge,
SavesFailureKind::GameRunning => Key::InstancesSavesReasonRunning,
SavesFailureKind::WriteFailed => Key::InstancesSavesReasonWrite,
})
}
// ------------------------------------------------- 存档写操作(M247 · SAVESOPS)
pub(in crate::pages::instances) fn saves_rename(self) -> &'static str {
self.text(Key::InstancesSavesRename)
}
pub(in crate::pages::instances) fn saves_backup(self) -> &'static str {
self.text(Key::InstancesSavesBackup)
}
pub(in crate::pages::instances) fn saves_delete(self) -> &'static str {
self.text(Key::InstancesSavesDelete)
}
/// 行内输入框的占位符(不是按钮文案:它说「该填什么」)。
pub(in crate::pages::instances) fn saves_rename_placeholder(self) -> &'static str {
self.text(Key::InstancesSavesRenamePlaceholder)
}
pub(in crate::pages::instances) fn saves_rename_confirm(self) -> &'static str {
self.text(Key::InstancesSavesRenameConfirm)
}
pub(in crate::pages::instances) fn saves_cancel(self) -> &'static str {
self.text(Key::InstancesSavesCancel)
}
/// 「正在处理…」:写请求在飞时顶替那一行的操作区。
pub(in crate::pages::instances) fn saves_busy(self) -> &'static str {
self.text(Key::InstancesSavesBusy)
}
pub(in crate::pages::instances) fn saves_delete_title(self) -> &'static str {
self.text(Key::InstancesSavesDeleteTitle)
}
/// 删除确认框的正文:`{dir} 和它里面的全部内容都会被删除,且不能撤销。`
pub(in crate::pages::instances) fn saves_delete_body(self, dir: &str) -> String {
self.0
.render(Key::InstancesSavesDeleteBody, &[("dir", dir)])
}
pub(in crate::pages::instances) fn saves_delete_confirm(self) -> &'static str {
self.text(Key::InstancesSavesDeleteConfirm)
}
/// 改名成功的回执:`已把 A 改名为 B。`
pub(in crate::pages::instances) fn saves_renamed(self, from: &str, to: &str) -> String {
self.0
.render(Key::InstancesSavesRenamed, &[("from", from), ("to", to)])
}
/// 删除成功的回执:`已删除 A。`
pub(in crate::pages::instances) fn saves_deleted(self, dir: &str) -> String {
self.0.render(Key::InstancesSavesDeleted, &[("dir", dir)])
}
/// 备份成功的回执:`已把 A 备份到 D:/…/A-…zip(1.2 MiB)。`
pub(in crate::pages::instances) fn saves_backed_up(
self,
dir: &str,
path: &str,
size: &str,
) -> String {
self.0.render(
Key::InstancesSavesBackedUp,
&[("dir", dir), ("path", path), ("size", size)],
)
}
/// 「跳过 N 个符号链接」(备份时没跟随的那些)。
pub(in crate::pages::instances) fn saves_backup_skipped(self, count: usize) -> String {
self.0.render(
Key::InstancesSavesBackupSkipped,
&[("count", &count.to_string())],
)
}
/// 重命名失败的主句:`重命名 A 失败:同名世界已存在`。
pub(in crate::pages::instances) fn saves_rename_failed(
self,
file: &str,
reason: &str,
) -> String {
self.0.render(
Key::InstancesSavesRenameFailed,
&[("file", file), ("reason", reason)],
)
}
/// 删除失败的主句。
pub(in crate::pages::instances) fn saves_delete_failed(
self,
file: &str,
reason: &str,
) -> String {
self.0.render(
Key::InstancesSavesDeleteFailed,
&[("file", file), ("reason", reason)],
)
}
/// 备份失败的主句。
pub(in crate::pages::instances) fn saves_backup_failed(
self,
file: &str,
reason: &str,
) -> String {
self.0.render(
Key::InstancesSavesBackupFailed,
&[("file", file), ("reason", reason)],
)
}
// ------------------------------------------------- 截图缩略图(M251 · SAVESHOTS)
/// 「这一格为什么没有图」的短句(`未生成预览` / 太大 / 读不出 / 解不出)。
pub(in crate::pages::instances) fn saves_preview_reason(
self,
kind: ScreenshotThumbKind,
) -> &'static str {
self.text(match kind {
ScreenshotThumbKind::TooLarge => Key::InstancesSavesPreviewTooLarge,
ScreenshotThumbKind::Unreadable => Key::InstancesSavesPreviewUnreadable,
ScreenshotThumbKind::Decode => Key::InstancesSavesPreviewDecode,
})
}
/// 「没轮到」那一句(超 60 张刷新预算 / 这一拍还没解到)。
pub(in crate::pages::instances) fn saves_preview_none(self) -> &'static str {
self.text(Key::InstancesSavesPreviewNone)
}
}
+4
View File
@@ -150,6 +150,10 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("InstancesSavesReasonTooLarge", "the world is too large for one zip", "这个世界太大,一个 zip 装不下"),
("InstancesSavesReasonWrite", "the write failed", "写盘失败"),
("InstancesSavesReasonRunning", "the game is running (exit it first)", "游戏正在运行(先退出再操作)"),
("InstancesSavesPreviewNone", "no preview", "未生成预览"),
("InstancesSavesPreviewTooLarge", "too large for a preview (over 8 MiB)", "文件过大,未生成预览(超过 8 MiB)"),
("InstancesSavesPreviewUnreadable", "the file could not be read", "读不出这张图"),
("InstancesSavesPreviewDecode", "not a decodable image", "解不出这张图(可能已损坏或格式不支持)"),
];
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
+8
View File
@@ -208,6 +208,14 @@ macro_rules! instances_entries {
// 实例在启动/运行中:三个写操作**主动不写盘**(不是"写盘失败"——Linux 上删一个正在跑的
// 世界是会成功的,所以这条要直说"先退出",别让用户以为是权限问题)。
InstancesSavesReasonRunning => ("the game is running (exit it first)", "游戏正在运行(先退出再操作)"),
// ── 截图**缩略图**(M251 · SAVESHOTS,D54)。
// 四句都是「这一格为什么没有图」:没轮到(超 60 张预算)/ 太大 / 读不出 / 解不出。
// 第 1 句与后三句分开是刻意的——"没生成"与"生成了但失败"对用户的含义不同,
// 前者点一下刷新可能就有了,后者得用户自己去动文件。
InstancesSavesPreviewNone => ("no preview", "未生成预览"),
InstancesSavesPreviewTooLarge => ("too large for a preview (over 8 MiB)", "文件过大,未生成预览(超过 8 MiB)"),
InstancesSavesPreviewUnreadable => ("the file could not be read", "读不出这张图"),
InstancesSavesPreviewDecode => ("not a decodable image", "解不出这张图(可能已损坏或格式不支持)"),
} }
};
}
+27 -8
View File
@@ -64,6 +64,10 @@ use std::path::Path;
use unlucky_design::palette::{self, ImageInput, ImageSource, Seed};
mod thumbnail;
pub use thumbnail::{thumbnail, THUMBNAIL_MAX_EDGE};
/// 交给 `unlucky_design` 前允许的最大边长(像素)。
///
/// 256 的依据:量化器自己只采 65536 个点(≈256²),再大的图在 `crates/design` 里也会被
@@ -186,31 +190,46 @@ pub fn input<'a>(loaded: &'a Result<Rgba8, ExtractError>) -> ImageInput<'a> {
}
fn finish<R: std::io::BufRead + std::io::Seek>(
mut reader: image::ImageReader<R>,
reader: image::ImageReader<R>,
) -> Result<Rgba8, ExtractError> {
Ok(fit(decode_with_limits(reader)?, MAX_EDGE))
}
/// 解码一个 reader(带 [`MAX_SOURCE_EDGE`] 头尺寸上限),**不缩放**。
///
/// 从 [`finish`] 里拆出来只有一个理由:取色([`load`])与缩略图([`thumbnail`])要**不同的
/// 目标边长**,而「读文件 + 嗅探格式 + 卡解压炸弹」这段必须只有一份(两份就会有一份忘了卡上限)。
pub(crate) fn decode_with_limits<R: std::io::BufRead + std::io::Seek>(
mut reader: image::ImageReader<R>,
) -> Result<image::RgbaImage, ExtractError> {
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_SOURCE_EDGE);
limits.max_image_height = Some(MAX_SOURCE_EDGE);
reader.limits(limits);
let decoded = reader.decode().map_err(ExtractError::Decode)?;
let rgba = decoded.to_rgba8();
Ok(decoded.to_rgba8())
}
/// 把解码结果等比缩到**长边 ≤ `max_edge`**(不放大:本来就小的图原样交付)。
///
/// `thumbnail` 是整数盒式采样:对取色(只关心色彩分布)与缩略图(160px 的墙)都够,
/// 且比 Lanczos 快一个量级。
pub(crate) fn fit(rgba: image::RgbaImage, max_edge: u32) -> Rgba8 {
let (width, height) = (rgba.width(), rgba.height());
let scaled = if width.max(height) > MAX_EDGE {
// 等比缩到长边 = MAX_EDGE。`thumbnail` 是整数快速采样,对取色(只关心色彩分布)
// 足够,且比 Lanczos 快一个量级。
let factor = f64::from(MAX_EDGE) / f64::from(width.max(height));
let scaled = if width.max(height) > max_edge {
let factor = f64::from(max_edge) / f64::from(width.max(height));
let target_width = ((f64::from(width) * factor).round() as u32).max(1);
let target_height = ((f64::from(height) * factor).round() as u32).max(1);
image::imageops::thumbnail(&rgba, target_width, target_height)
} else {
rgba
};
Ok(Rgba8 {
Rgba8 {
width: scaled.width(),
height: scaled.height(),
pixels: scaled.into_raw(),
})
}
}
#[cfg(test)]
+92
View File
@@ -187,3 +187,95 @@ fn input_borrows_without_copying() {
ImageInput::Undecodable => panic!("刚解码成功却报了 undecodable"),
}
}
// ---------------------------------------------------------------- 缩略图(M251 · D54)
/// 一次性临时目录(本 crate 不引第三方 tempfile:整仓都没有)。
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 写一个真 PNG 到临时目录,返回 `(目录守卫, 文件路径)`。
fn png_on_disk(tag: &str, image: &RgbaImage) -> (TempDir, std::path::PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("palette-thumb-{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
let path = dir.join("shot.png");
std::fs::write(&path, encode_png(image)).expect("写 PNG");
(TempDir(dir), path)
}
/// 真 PNG → 等比缩到给定长边。
///
/// 抓得住:`thumbnail` 拿错目标边长(例如误用取色那条路的 `MAX_EDGE = 256`)、宽高比被拉平、
/// 缩放后字节数对不上 `width × height × 4`。
/// 抓不住:缩放**画质**(盒式采样的观感只能靠出图看);像素内容是否正确(只查尺寸与不透明通道)。
#[test]
fn thumbnail_scales_a_real_png_to_the_asked_edge() {
let (_dir, path) = png_on_disk("scale", &solid_image(40, 20, [0x2E, 0x7D, 0x4F]));
let thumb = thumbnail(&path, 10).expect("真 PNG 应当出缩略图");
assert_eq!((thumb.width(), thumb.height()), (10, 5), "等比缩到长边 10");
assert_eq!(thumb.pixels().len(), 10 * 5 * 4);
// 纯色图缩完还是那个色(盒式平均不该把纯色抹成别的色)。
assert_eq!(&thumb.pixels()[..4], &[0x2E, 0x7D, 0x4F, 0xFF]);
}
/// 小图**不放大**:图本身比墙上的格子小就原样交付(放大只会糊)。
///
/// 抓得住:有人在 `fit` 里把「长边超限才缩」改成无条件缩(那会把 8×6 的图拉成 160×120)。
#[test]
fn thumbnail_never_upscales() {
let (_dir, path) = png_on_disk("small", &solid_image(8, 6, [0x67, 0x50, 0xA4]));
let thumb = thumbnail(&path, THUMBNAIL_MAX_EDGE).expect("小图也应当成功");
assert_eq!((thumb.width(), thumb.height()), (8, 6));
}
/// 坏文件给**结构化错误**,不 panic、不返回灰图。
///
/// 抓得住:把 `?` 换成 `unwrap`/`unwrap_or_default`(前者 panic 让用例挂,后者返回一张图让
/// `expect_err` 挂)。
/// 抓不住:错误文案的措辞(那是 `image` 的原文,只进括号)。
#[test]
fn a_broken_png_is_a_decode_error_not_a_panic() {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("palette-thumb-bad-{}-{seq}", std::process::id()));
std::fs::create_dir_all(&dir).expect("建临时目录");
let guard = TempDir(dir.clone());
let path = dir.join("2024-05-01_10.00.00.png");
std::fs::write(&path, b"\x89PNG\r\n\x1a\n truncated garbage").expect("写坏文件");
match thumbnail(&path, THUMBNAIL_MAX_EDGE) {
Err(ExtractError::Decode(_)) => {}
other => panic!("坏 PNG 应当给 Decode 错误,实际 {other:?}"),
}
drop(guard);
}
/// 文件不存在 ⇒ `Io`(与「解不出来」分开:一个是没读到,一个是读到了但读不懂)。
#[test]
fn a_missing_file_is_an_io_error() {
let path = std::env::temp_dir().join("palette-thumb-does-not-exist.png");
let _ = std::fs::remove_file(&path);
match thumbnail(&path, THUMBNAIL_MAX_EDGE) {
Err(ExtractError::Io(_)) => {}
other => panic!("不存在的文件应当给 Io 错误,实际 {other:?}"),
}
}
/// `max_edge = 0` 按 1 处理(防调用方传 0 得到 0×0 的图,墙上一格空着还查不出原因)。
#[test]
fn a_zero_edge_is_treated_as_one() {
let (_dir, path) = png_on_disk("zero", &solid_image(40, 20, [0x3C, 0x8D, 0xBC]));
let thumb = thumbnail(&path, 0).expect("0 也应当能出图");
assert_eq!((thumb.width(), thumb.height()), (1, 1));
}
+57
View File
@@ -0,0 +1,57 @@
//! 缩略图:路径 → 限尺寸的 [`Rgba8`](D54 第 1 条,M251 · SAVESHOTS)。
//!
//! ## 为什么落在本 crate
//!
//! D54 定的分层没有变:图片解码**集中在 `palette-extract`**(`crates/design` 零第三方依赖的
//! 代价就是把解码摊到这里;`app-iced` 另有一份 `image` 只用于截图编码与背景图,见它自己的
//! `Cargo.toml` 注释)。截图是 PNG,本 crate 的 `image` 已经开了 `png`/`jpeg`/`bmp` 解码 feature,
//! 所以加这条路**零新依赖、零新 crate、零新 feature**——只是把「解码 + 限尺寸」这段已有的能力
//! 换个目标边长([`THUMBNAIL_MAX_EDGE`])暴露一次。
//!
//! ## 不落盘
//!
//! 这里**只返回像素**,不写任何缓存文件(D54 第 2 条):写 PNG 需要编码器,而编码器只为一份
//! 磁盘缓存引入不划算;内存缓存归调用方(presenter 的 `host::saves::thumbs`)。
//!
//! ## 失败不 panic
//!
//! 读不到 / 不是图片 / 头里写着 40000×40000,全都变成 [`ExtractError`](与取色那条路同一族
//! 错误)。调用方据此给「逐条可见」的失败原因——**不许**在这里「读不到就画个灰块」。
//!
//! 与 [`crate::load`] 的区别只有一条:目标边长。`load` 钉 `MAX_EDGE = 256`(取色只需要色彩分布),
//! 这里由调用方给(缩略图墙用 160)。
//!
//! ```no_run
//! use unlucky_palette_extract::{thumbnail, THUMBNAIL_MAX_EDGE};
//!
//! match thumbnail("screenshots/2024-05-01_10.00.00.png", THUMBNAIL_MAX_EDGE) {
//! Ok(thumb) => println!("{}×{}", thumb.width(), thumb.height()),
//! Err(error) => eprintln!("这张图没有缩略图:{error}"),
//! }
//! ```
use std::path::Path;
use crate::{decode_with_limits, fit, ExtractError, Rgba8};
/// 缩略图的最大边(像素)。D54 定 160。
///
/// 依据:一屏缩略图墙按 4 列排、详情列宽约 700px ⇒ 每格 ≈ 160px。再大只是多占内存
/// (160² × 4B ≈ 100KB/张,60 张 ≈ 6MB),肉眼在墙上看不出差别。
pub const THUMBNAIL_MAX_EDGE: u32 = 160;
/// 从文件路径解码并缩到**长边 ≤ `max_edge`**(等比、不放大),返回 RGBA8 像素。
///
/// 格式按**魔数**嗅探,不看扩展名(与 [`crate::load`] 同一条路:用户把 `.jpg` 改名成 `.png`
/// 也照样读得出来)。`max_edge = 0` 按 1 处理(防调用方传 0 得到 0×0 的图)。
///
/// # Errors
///
/// - [`ExtractError::Io`]:文件不存在 / 无权限 / 被占用 / 打不开;
/// - [`ExtractError::Decode`]:不是认得的图片、文件截断、或头尺寸超过 16384(解压炸弹)。
pub fn thumbnail(path: impl AsRef<Path>, max_edge: u32) -> Result<Rgba8, ExtractError> {
let max_edge = max_edge.max(1);
let reader = image::ImageReader::open(path).map_err(ExtractError::Io)?;
let reader = reader.with_guessed_format().map_err(ExtractError::Io)?;
Ok(fit(decode_with_limits(reader)?, max_edge))
}
+5
View File
@@ -24,6 +24,11 @@ serde_json.workspace = true
# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。
unlucky-core = { path = "../core", version = "0.2.3" }
# M251(D54 第 1/4 条):截图缩略图在**后台线程**里解码,解码能力只能来自 palette-extract
# (`image` 一个字节都不进 presenter,否则「解码集中在一处」这条边界当场作废)。
# 方向只能是 presenter → palette-extract(反向不许);那边也不许反过来依赖 core/presenter。
unlucky-palette-extract = { path = "../palette-extract", version = "0.2.3" }
[lints]
workspace = true
+3 -2
View File
@@ -86,10 +86,11 @@ pub use pack::{
pub use settings::{
find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION,
};
// M235: 实例存档 / 截图的只读列表
// M235: 实例存档 / 截图的只读列表(M251 加逐张缩略图预览)
pub use saves::{
SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView,
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotPreview, ScreenshotThumbFailure,
ScreenshotThumbKind, ScreenshotView, ThumbnailView, WorldView,
};
// M159: 自更新
pub use update::{
+15 -2
View File
@@ -39,6 +39,10 @@ use serde::{Deserialize, Serialize};
use crate::dto::InstanceId;
mod preview;
pub use preview::{ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ThumbnailView};
/// app → host 的存档指令。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesCommand {
@@ -129,13 +133,16 @@ pub enum SavesOped {
pub struct WorldView {
/// 磁盘上的目录名(这一版它就是展示名:`LevelName` 在 `level.dat` 里,本版不解析 NBT)。
pub dir: String,
/// 有没有 `icon.png`(只判存在,不解码——缩略图墙不在 U3 范围内)。
/// 有没有 `icon.png`(只判存在,不解码)。
///
/// M251 之后**世界图标仍未解**:缩略图墙([`ScreenshotPreview`])只覆盖 `screenshots/`,
/// 这是 D54 的范围。这行字眼原来写的是「缩略图墙不在 U3 范围内」,那句话已失真。
pub icon: bool,
/// 目录 mtime(UTC 毫秒;`None` = 取不到)。
pub modified_ms: Option<u64>,
}
/// 一张截图(**只给元数据**,不给像素:解码图片要引依赖,U3 明确不做)。
/// 一张截图(元数据 + **预览**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScreenshotView {
/// 磁盘文件名(含扩展名)。
@@ -144,6 +151,12 @@ pub struct ScreenshotView {
pub bytes: u64,
/// 文件 mtime(UTC 毫秒;`None` = 取不到)。
pub modified_ms: Option<u64>,
/// 预览(缩略图像素 / 没生成 / 具名失败原因),见 [`ScreenshotPreview`]。
///
/// 像素**不落盘**(D54 第 2 条):解码在 host 的后台线程里做完,随这份快照一次性交给
/// 壳层。`(file, modified_ms, bytes)` 同时也是 host 侧缓存键的三段——三者任一变了就是
/// 另一张图,必须重解。
pub preview: ScreenshotPreview,
}
/// 被跳过的条目来自哪个目录。
+88
View File
@@ -0,0 +1,88 @@
//! 一张截图的**预览**(M251 · SAVESHOTS,D54 第 2/6 条)。
//!
//! ## 像素为什么走 DTO
//!
//! D54 定「**不落盘缩略图**」:写 PNG 要编码器,而编码器只为一份磁盘缓存引入不划算。于是像素
//! 就直接躺在快照里跟着 IPC 面走,由壳层用 `iced::widget::image::Handle::from_rgba` 建句柄。
//! 代价是快照变大(60 张 × 160² × 4B ≈ 6MB),收益是零编码、零临时文件、零清理。
//!
//! ## 三种状态,一种都不许静默
//!
//! | 状态 | 什么时候 | 界面 |
//! |---|---|---|
//! | [`ScreenshotPreview::Ready`] | 解出来了 | 缩略图 |
//! | [`ScreenshotPreview::NotGenerated`] | 本次刷新超出 60 张上限(按 mtime 倒序只解前 60) | 「未生成预览」占位 |
//! | [`ScreenshotPreview::Failed`] | 超过 8 MiB / 读不出来 / 解不出来 | 占位 + **逐条**原因 |
//!
//! **不拼人读文案**(M161):这里只有结构与 [`ScreenshotThumbKind`],句子由 app 层按 i18n key 现求。
//!
//! ## 为什么失败也带 `detail`
//!
//! 「读不出来」有五种完全不同的原因(权限 / 被占用 / 文件已删 / 是目录 / 盘掉了),用户能自己
//! 处理的那几种全靠这句话。原文只进括号,不进主句(与 [`super::SavesFailureView`] 同一条口径)。
use serde::{Deserialize, Serialize};
/// 一张截图的预览状态。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScreenshotPreview {
/// 还没生成:本次刷新超出上限(按 mtime 倒序只解前 60 张,上限常量在
/// `crate::host::saves::thumbs::MAX_PER_REFRESH`——策略住在解码那一侧,DTO 只表达状态)。
NotGenerated,
/// 解出来了(RGBA8,行主序,`width × height × 4`)。
Ready(ThumbnailView),
/// 没解出来(逐条可见的原因 + 原文)。
Failed(ScreenshotThumbFailure),
}
impl ScreenshotPreview {
/// 这一拍有没有可画的像素。
#[must_use]
pub fn thumbnail(&self) -> Option<&ThumbnailView> {
match self {
Self::Ready(view) => Some(view),
Self::NotGenerated | Self::Failed(_) => None,
}
}
}
/// 一张缩略图的像素(**未落盘**,见模块文档)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThumbnailView {
/// 宽(像素)。
pub width: u32,
/// 高(像素)。
pub height: u32,
/// RGBA8 字节,行主序,长度必须是 `width × height × 4`。
pub pixels: Vec<u8>,
}
impl ThumbnailView {
/// 像素数与宽高对得上吗(壳层建 `Handle` 之前**必须**成立:`from_rgba` 不校验长度,
/// 长度不对就是一张花屏图或者一次 panic 后的空窗)。
#[must_use]
pub fn is_consistent(&self) -> bool {
let expected = u64::from(self.width) * u64::from(self.height) * 4;
expected == self.pixels.len() as u64
}
}
/// 一张截图的预览为什么没出来(**每一种在 UI 上都有一句话**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScreenshotThumbFailure {
/// 大类。
pub kind: ScreenshotThumbKind,
/// 底层原文(OS 错误 / 解码器原文);只进括号,不进主句。
pub detail: String,
}
/// 预览失败的大类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScreenshotThumbKind {
/// 单张超过解码上限(8 MiB):**不解码**,直接跳过(D54 第 5 条)。
TooLarge,
/// 读不出来(不存在 / 无权限 / 被占用)。
Unreadable,
/// 读到了但解不出来(坏 PNG / 不支持的格式)。
Decode,
}
+66
View File
@@ -18,6 +18,12 @@ fn view() -> SavesListView {
file: "2024-05-01_10.00.00.png".to_string(),
bytes: 1024,
modified_ms: None,
// M251:像素真的能过 IPC(4 字节 = 1×1 RGBA),这是「不落盘直接交给壳层」的前提。
preview: ScreenshotPreview::Ready(ThumbnailView {
width: 1,
height: 1,
pixels: vec![0x2E, 0x7D, 0x4F, 0xFF],
}),
}],
skipped: vec![SavesSkipView {
area: SavesArea::Screenshots,
@@ -121,6 +127,66 @@ fn empty_means_no_worlds_and_no_screenshots() {
file: "a.png".to_string(),
bytes: 0,
modified_ms: None,
preview: ScreenshotPreview::NotGenerated,
});
assert!(!view.is_empty());
}
/// 预览的三种状态都要能过 IPC,且**失败原因逐条带得回来**(M251 · D54 第 6 条)。
#[test]
fn previews_round_trip_in_every_state() {
let failure = ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: "9000000 > 8388608 字节".to_string(),
};
for preview in [
ScreenshotPreview::NotGenerated,
ScreenshotPreview::Ready(ThumbnailView {
width: 2,
height: 1,
pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
}),
ScreenshotPreview::Failed(failure),
] {
let text = serde_json::to_string(&preview).expect("序列化");
assert_eq!(
serde_json::from_str::<ScreenshotPreview>(&text).expect("反序列化"),
preview
);
}
}
/// 「像素长度与宽高对得上」这条判据本身(壳层建 `Handle` 前要它成立)。
#[test]
fn thumbnail_consistency_catches_a_short_buffer() {
let good = ThumbnailView {
width: 2,
height: 2,
pixels: vec![0; 16],
};
assert!(good.is_consistent());
let short = ThumbnailView {
pixels: vec![0; 15],
..good.clone()
};
assert!(!short.is_consistent(), "少一个字节也必须判不一致");
}
/// 只有 `Ready` 才有像素可画(另外两支都是占位,界面不许把它们当成「黑图」)。
#[test]
fn only_ready_hands_out_pixels() {
assert!(ScreenshotPreview::Ready(ThumbnailView {
width: 1,
height: 1,
pixels: vec![0, 0, 0, 255],
})
.thumbnail()
.is_some());
assert!(ScreenshotPreview::NotGenerated.thumbnail().is_none());
assert!(ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::Unreadable,
detail: "access denied".to_string(),
})
.thumbnail()
.is_none());
}
+22 -11
View File
@@ -73,13 +73,16 @@ use unlucky_core::platform::opener;
use crate::dto::{
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp,
SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView,
WorldView,
SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotPreview,
ScreenshotView, WorldView,
};
use super::env::HostEnv;
use super::job::{spawn, JobSender};
/// 截图的缩略图:后台解码 + 进程级 memo(D54 第 3/4/5 条,见模块文档)
pub mod thumbs;
mod write;
/// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。
@@ -254,7 +257,23 @@ pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathB
}
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
///
/// **截图带缩略图**(M251):像素在这里(后台线程里)解好、随快照一起交出去,UI 只画。
/// D54 明写「不许在渲染回调里解码」——这条纪律的落点就是「`view` 里 attach,`view()` 只在
/// 后台线程被调」。解码失败/超限/超预算的每一张都带自己的状态,不留白(D54 第 6 条)。
fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesListView {
let mut screenshots: Vec<ScreenshotView> = scan
.screenshots
.iter()
.map(|shot| ScreenshotView {
file: shot.file.clone(),
bytes: shot.bytes,
modified_ms: shot.modified_ms,
// 先摆占位,再让 thumbs 按 mtime 倒序挑前 60 张去解(顺序与上限都在那一个模块里)。
preview: ScreenshotPreview::NotGenerated,
})
.collect();
thumbs::attach(&scan.screenshots_dir, &mut screenshots);
SavesListView {
instance: instance.to_string(),
dir: scan.dir.display().to_string(),
@@ -270,15 +289,7 @@ fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesLi
.collect(),
screenshots_dir: scan.screenshots_dir.display().to_string(),
screenshots_exists: scan.screenshots_exists,
screenshots: scan
.screenshots
.iter()
.map(|shot| ScreenshotView {
file: shot.file.clone(),
bytes: shot.bytes,
modified_ms: shot.modified_ms,
})
.collect(),
screenshots,
skipped: scan
.skipped
.iter()
+202
View File
@@ -0,0 +1,202 @@
//! 截图的**后台解码 + 进程级 memo**(M251 · SAVESHOTS,落地 D54 第 3/4/5/6 条)。
//!
//! ## 为什么解码在后台线程
//!
//! 一屏 60 张 PNG 解码是几百毫秒量级的工作。D54 明写「**不许**在渲染回调里解码」:把这段放进
//! `iced` 的 `view()` 会直接把帧率打穿。所以解码发生在 [`super::spawn_list`] 那条后台线程里
//! (`job::spawn`,与 `host/saves.rs` 同一模式),UI 那一侧只收一份**带像素的快照**。
//!
//! ## 缓存键为什么必须是三段
//!
//! `(路径, mtime, 大小)`——**只按路径**是错的,而且是那种「测试不会红」的错:
//!
//! | 场景 | 只按路径的后果 |
//! |---|---|
//! | 游戏又存了一张同名截图(覆盖) | 永远显示上一张的缩略图 |
//! | 用户拿同名文件替换(修图后放回) | 同上,且没有任何日志 |
//!
//! 带上 mtime 与大小之后,覆盖 ⇒ 键变了 ⇒ 重解。这条由
//! `tests::a_changed_file_is_decoded_again` 钉住(把 mtime 从键里去掉,它必红)。
//!
//! ## 为什么是**进程级** memo 而不是状态字段
//!
//! 解码发生在 `spawn_list` 起的线程里,而那条线程拿到的东西只有 `HostEnv` + `tx`(见
//! `host/saves.rs`)。把缓存挂到 `CoreHost` 上要改 `host/mod.rs`(贴着 400 行)与
//! `host/dispatch.rs`(M251 的 scope 外),换来的语义与这里**完全一样**:键是全路径,多实例
//! 共享一份不会串味;它也不是业务状态,只是一份「同样的键不再重解」的记忆。
//!
//! ## 两条硬上限(D54 第 5 条)
//!
//! | 上限 | 值 | 为什么 |
//! |---|---|---|
//! | 单张字节数 | [`MAX_SOURCE_BYTES`] = 8 MiB | 一张 200MB 的 PNG 解码要几 GB 内存;**解码前**查大小,超了就不进解码器,并留一条可见原因 |
//! | 每次刷新张数 | [`MAX_PER_REFRESH`] = 60 | 按 mtime 倒序只解前 60 张(最近玩的那几屏最有用),其余给「未生成预览」占位 |
//!
//! 上限是**逐条可见**的:跳过的那张在快照里带 [`ScreenshotPreview::Failed`] 或
//! [`ScreenshotPreview::NotGenerated`],不是静默少一张(D54 第 6 条)。
//!
//! ## 缓存不会无限长
//!
//! 键里有 mtime,覆盖一次就多一个键。所以超过 [`MAX_CACHE_ENTRIES`] 时只保留**这一次刷新碰到
//! 的键**(换目录、删截图之后旧键一起掉)。代价是极端情况下重解一次,收益是常驻内存有界。
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use unlucky_palette_extract::{thumbnail, ExtractError, THUMBNAIL_MAX_EDGE};
use crate::dto::{
ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ScreenshotView, ThumbnailView,
};
/// 单张截图的解码上限(字节)。超过就**不解码**(D54 第 5 条)。
pub const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
/// 每次刷新最多解几张(按 mtime 倒序取前这么多)。
pub const MAX_PER_REFRESH: usize = 60;
/// 缓存的条目上限(超过就只留这一拍碰到的键)。
///
/// **算过内存**:一张缩略图最多 `160×160×4 = 102.4 KB`,120 条 ⇒ **上限 ≈ 12 MB**;
/// 一次刷新最多碰 60 张(≈6 MB),所以 120 = 「当前这一屏 + 上一屏」的余量。
/// 定这条上限的背景:缓存是**进程级静态**(见 [`cache`]),不随实例/数据根释放,
/// 所以要有一个能算出来的天花板,而不是「反正删了就少一张」。
pub const MAX_CACHE_ENTRIES: usize = 120;
/// 缓存键:**(完整路径, mtime 毫秒, 字节数)**。
///
/// 三段缺一不可,理由见模块文档的表格。`mtime` 是 `Option`:取不到时间的文件用 `None` 占位
/// (那种文件本来就没有「变了」的可靠判据,大小仍然兜住一部分)。
type Key = (String, Option<u64>, u64);
/// 缓存里存的是**结局**(成功或失败)。
///
/// 失败也缓存:一张坏 PNG 不该每次刷新都重解一遍(那是「滚动一次卡一次」的另一种来源)。
/// 键里带 mtime ⇒ 用户修好文件(mtime 变)之后自然会重解。
type Cache = HashMap<Key, Result<ThumbnailView, ScreenshotThumbFailure>>;
fn cache() -> &'static Mutex<Cache> {
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 清空缓存(**只给用例**:这是进程级静态,用例之间必须能从已知状态起跑)。
///
/// 生产路径没有任何调用点——键里带完整路径,两个数据根/两个实例共用一个静态表也不会串味
/// (见模块文档「为什么是进程级 memo」);但**用例**会反复用短路径造键,谁都不该依赖
/// 「上一个用例刚好留了/没留什么」。
#[cfg(test)]
pub(super) fn clear_cache() {
cache()
.lock()
.unwrap_or_else(|poison| poison.into_inner())
.clear();
}
/// 给一批截图挂上预览(**在后台线程里调**,`super::view` 的唯一调用点)。
pub(super) fn attach(dir: &Path, views: &mut [ScreenshotView]) {
attach_with(dir, views, &mut decode);
}
/// [`attach`] 的可测内核:解码器是一个参数,于是用例能数「到底进了几次解码器」。
///
/// 这类断言正是 D54 要求的那条「**缓存命中不重复解码**」:数的是解码器的调用次数,不是
/// 「界面上看起来一样」。
fn attach_with(
dir: &Path,
views: &mut [ScreenshotView],
decode: &mut impl FnMut(&Path, u64) -> Result<ThumbnailView, ScreenshotThumbFailure>,
) {
if views.is_empty() {
return;
}
// ① 排序:mtime 倒序(取不到的排最后),同刻按文件名倒序兜底(两次刷新之间不抖)。
let mut order: Vec<usize> = (0..views.len()).collect();
order.sort_by(|&left, &right| {
views[right]
.modified_ms
.cmp(&views[left].modified_ms)
.then_with(|| views[right].file.cmp(&views[left].file))
});
let mut store = cache().lock().unwrap_or_else(|poison| poison.into_inner());
let mut touched: Vec<Key> = Vec::with_capacity(order.len().min(MAX_PER_REFRESH));
for (rank, &index) in order.iter().enumerate() {
// ② 超出这一拍的解码预算:占位,不碰盘、不进解码器。
if rank >= MAX_PER_REFRESH {
views[index].preview = ScreenshotPreview::NotGenerated;
continue;
}
let (file, bytes, modified) = {
let shot = &views[index];
(shot.file.clone(), shot.bytes, shot.modified_ms)
};
// ③ 单张太大:**解码前**就拒,理由逐条可见。
if bytes > MAX_SOURCE_BYTES {
views[index].preview = ScreenshotPreview::Failed(ScreenshotThumbFailure {
kind: ScreenshotThumbKind::TooLarge,
detail: format!("{bytes} > {MAX_SOURCE_BYTES}"),
});
continue;
}
// ④ 缓存 → 解码 → 回填。
let path = dir.join(&file);
let key: Key = (path.display().to_string(), modified, bytes);
let outcome = match store.get(&key) {
Some(cached) => cached.clone(),
None => {
let computed = decode(&path, bytes);
store.insert(key.clone(), computed.clone());
computed
}
};
touched.push(key);
views[index].preview = match outcome {
Ok(view) => ScreenshotPreview::Ready(view),
Err(failure) => ScreenshotPreview::Failed(failure),
};
}
// ⑤ 有界:只留这一拍碰到的键(旧的一律掉)。
prune(&mut store, &touched);
}
/// 淘汰策略:超过 [`MAX_CACHE_ENTRIES`] 时只留这一拍碰到的键。
///
/// 单独成函数是为了**能在本地 `Cache` 上测**:全局缓存被所有用例共享,谁往它里面灌 240+ 条
/// 就会顺手把别的用例刚缓存的键清掉(M251 实测:并行跑时 `the_second_identical_refresh_...`
/// 会假红)。策略与「共享状态」解耦之后,两条判据各自确定。
fn prune(store: &mut Cache, touched: &[Key]) {
if store.len() > MAX_CACHE_ENTRIES {
store.retain(|key, _| touched.contains(key));
}
}
/// 真解码(唯一碰 `image` 的地方;错都在 `palette-extract` 里分好类了)。
fn decode(path: &Path, _bytes: u64) -> Result<ThumbnailView, ScreenshotThumbFailure> {
match thumbnail(path, THUMBNAIL_MAX_EDGE) {
Ok(image) => Ok(ThumbnailView {
width: image.width(),
height: image.height(),
pixels: image.pixels().to_vec(),
}),
Err(error) => Err(failure(&error)),
}
}
/// `palette-extract` 的错误 → 预览失败的大类 + 原文(不翻译,翻译在 app 层)。
fn failure(error: &ExtractError) -> ScreenshotThumbFailure {
let kind = match error {
ExtractError::Io(_) => ScreenshotThumbKind::Unreadable,
// 取色失败那条支在本路径上不可能出现(`thumbnail` 不做量化),但穷尽 match 比
// 通配兜底诚实:真出现了也算「解不出来」,不会被当成读到了。
ExtractError::Decode(_) | ExtractError::Palette(_) => ScreenshotThumbKind::Decode,
};
ScreenshotThumbFailure {
kind,
detail: error.to_string(),
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,319 @@
//! `host::saves::thumbs` 的用例:**数解码器调用次数**,不靠"看起来一样"。
//!
//! 每一条都按 D54 的判据写,并且都带一句「抓得住什么 / 抓不住什么」。T27 要求的两条变异都有对应用例:
//!
//! | 变异 | 必红的用例 |
//! |---|---|
//! | 缓存键去掉 mtime(或改成只按路径) | [`a_changed_file_is_decoded_again`](同一路径、同一大小、只改 mtime ⇒ 必须重解)与 [`a_changed_size_is_decoded_again`] |
//! | 单张 8 MiB 上限改成无上限 | [`a_file_over_the_size_cap_never_reaches_the_decoder`](解码器调用次数必须为 0) |
//!
//! 每个用例开头都调 [`clear_cache`]:缓存是进程级静态,用例之间不该互相借光(M251 复审 r1)。
//!
//! 抓不住:缩略图**画质**(盒式采样的观感只能靠出图看);缓存淘汰策略的观感(`MAX_CACHE_ENTRIES`
//! 只有一条「不会无限长」的结构性断言,没有逐条淘汰用例)。
use std::path::{Path, PathBuf};
use super::*;
/// 一张真的 8×6 PNG(143 字节,RGBA,逐像素渐变)。
///
/// 内嵌字节而不是引素材文件:presenter 的 dev-dependencies 里没有图片编码器,而这条用例要的
/// 正是「**真**解码器能读的真 PNG」——手写的 143 个字节比任何 mock 都硬。
const PNG_8X6: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 6, 8, 6, 0,
0, 0, 254, 5, 223, 251, 0, 0, 0, 86, 73, 68, 65, 84, 120, 218, 21, 202, 33, 1, 195, 64, 12, 0,
192, 87, 50, 92, 17, 47, 162, 56, 74, 2, 171, 34, 184, 34, 34, 98, 56, 134, 182, 123, 112, 236,
214, 90, 207, 239, 195, 38, 72, 138, 102, 88, 235, 18, 216, 4, 73, 209, 204, 117, 194, 45, 176,
9, 146, 162, 153, 251, 132, 20, 216, 4, 73, 209, 76, 158, 240, 10, 108, 130, 164, 104, 230, 61,
225, 43, 176, 9, 146, 162, 25, 254, 48, 255, 110, 65, 247, 55, 89, 206, 0, 0, 0, 0, 73, 69, 78,
68, 174, 66, 96, 130,
];
/// 一次性临时目录(本 crate 不引第三方 tempfile)。
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn temp_dir(tag: &str) -> TempDir {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("ull-thumbs-{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
TempDir(path)
}
/// 目录里放一张真 PNG,返回它的字节数。
fn write_png(dir: &Path, file: &str) -> u64 {
let path = dir.join(file);
std::fs::write(&path, PNG_8X6).expect("写 PNG");
std::fs::metadata(&path).expect("读元数据").len()
}
/// 一张「快照里的截图」(`preview` 由被测代码填)。
fn shot(file: &str, bytes: u64, modified_ms: Option<u64>) -> ScreenshotView {
ScreenshotView {
file: file.to_string(),
bytes,
modified_ms,
preview: ScreenshotPreview::NotGenerated,
}
}
/// **判据 ①**:真 PNG 出缩略图(像素 + 宽高 + 长度自洽)。
///
/// 抓得住:解码这条路断了(回 `NotGenerated`)、缩略图边长没按 `THUMBNAIL_MAX_EDGE` 走、
/// `pixels` 长度与宽高对不上(壳层 `from_raw_rgba` 会画出花屏)。
#[test]
fn a_real_png_becomes_a_thumbnail() {
clear_cache();
let dir = temp_dir("ready");
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
let mut views = [shot(
"2024-05-01_10.00.00.png",
bytes,
Some(1_714_557_600_000),
)];
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
attach_with(&dir.0, &mut views, &mut counting);
assert_eq!(calls.get(), 1, "一张新图应当正好进一次解码器");
let ready = views[0].preview.thumbnail().expect("真 PNG 应当出缩略图");
assert_eq!((ready.width, ready.height), (8, 6), "8×6 的图不放大");
assert!(ready.is_consistent(), "像素长度与宽高对不上");
assert_eq!(&ready.pixels[..4], &[0, 0, 128, 255], "左上角那一格的颜色");
}
/// **判据 ②**:坏文件给**结构化错误**(逐条可见),不 panic、不静默少一张。
#[test]
fn a_broken_file_is_a_visible_reason() {
clear_cache();
let dir = temp_dir("broken");
std::fs::write(dir.0.join("bad.png"), b"\x89PNG\r\n\x1a\n garbage").expect("写坏文件");
let bytes = std::fs::metadata(dir.0.join("bad.png"))
.expect("元数据")
.len();
let mut views = [shot("bad.png", bytes, Some(1))];
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
attach_with(&dir.0, &mut views, &mut counting);
assert_eq!(calls.get(), 1, "坏文件也要真的试一次解码(不许提前判死)");
match &views[0].preview {
ScreenshotPreview::Failed(failure) => {
assert_eq!(failure.kind, ScreenshotThumbKind::Decode);
assert!(!failure.detail.is_empty(), "原因被吞掉了");
}
other => panic!("坏 PNG 应当给 Failed,实际 {other:?}"),
}
}
/// **判据 ③**:超过 8 MiB 的图**不进解码器**,且原因可见(T27 变异的目标)。
///
/// 抓得住:把上限改成「无上限」或把判断挪到解码之后(`calls` 会从 0 变成 1)。
/// 抓不住:8 MiB 这个数字本身合不合理(那是 D54 定的产品取舍)。
#[test]
fn a_file_over_the_size_cap_never_reaches_the_decoder() {
clear_cache();
let dir = temp_dir("toolarge");
let file = "huge.png";
std::fs::write(dir.0.join(file), vec![0u8; MAX_SOURCE_BYTES as usize + 1]).expect("写大文件");
let mut views = [shot(file, MAX_SOURCE_BYTES + 1, Some(1))];
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
attach_with(&dir.0, &mut views, &mut counting);
assert_eq!(calls.get(), 0, "超过 8 MiB 的图一个字节都不该进解码器");
match &views[0].preview {
ScreenshotPreview::Failed(failure) => {
assert_eq!(failure.kind, ScreenshotThumbKind::TooLarge);
assert!(
failure.detail.contains(&MAX_SOURCE_BYTES.to_string()),
"括号里要带上限,实际 {}",
failure.detail
);
}
other => panic!("超限的图应当给 Failed(TooLarge),实际 {other:?}"),
}
}
/// **判据 ④**:第 61 张不解码(按 mtime 倒序)。
///
/// 抓得住:上限被去掉(`calls` 会变 61)、排序反了(最早的那张反而被解、最新的给占位)、
/// 占位被画成失败(`NotGenerated` 与 `Failed` 是两种状态:一个是「没轮到」,一个是「试过但不行」)。
#[test]
fn the_sixty_first_screenshot_is_not_decoded() {
clear_cache();
let dir = temp_dir("cap");
let mut views: Vec<ScreenshotView> = (0..MAX_PER_REFRESH + 1)
.map(|index| {
let file = format!("2024-05-01_10.00.{index:02}.png");
let bytes = write_png(&dir.0, &file);
// index 越小越新:排序之后 index 0 应当是 rank 0。
shot(&file, bytes, Some(1_714_557_600_000 - index as u64))
})
.collect();
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
attach_with(&dir.0, &mut views, &mut counting);
assert_eq!(
calls.get(),
MAX_PER_REFRESH as u32,
"只该解前 {MAX_PER_REFRESH} 张"
);
assert!(
views[..MAX_PER_REFRESH]
.iter()
.all(|view| view.preview.thumbnail().is_some()),
"最新的 {MAX_PER_REFRESH} 张都该有缩略图"
);
assert_eq!(
views[MAX_PER_REFRESH].preview,
ScreenshotPreview::NotGenerated,
"第 61 张应当是「未生成预览」占位"
);
}
/// **判据 ⑤**:缓存命中不重复解码(同一 (路径, mtime, 大小) 第二次不进解码器)。
///
/// 抓得住:缓存根本没接上(`calls` 变 2)、键里多加了会让命中失效的东西(例如把「本次调用
/// 序号」混进键)。
#[test]
fn the_second_identical_refresh_does_not_redecode() {
clear_cache();
let dir = temp_dir("hit");
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
let mut first = [shot("2024-05-01_10.00.00.png", bytes, Some(7))];
attach_with(&dir.0, &mut first, &mut counting);
let mut second = [shot("2024-05-01_10.00.00.png", bytes, Some(7))];
attach_with(&dir.0, &mut second, &mut counting);
assert_eq!(calls.get(), 1, "第二次刷新不许再进解码器");
assert_eq!(first[0].preview, second[0].preview, "两拍的缩略图必须一致");
}
/// **判据 ⑥(T27 的第一条变异靶子)**:文件被覆盖(mtime 变了)必须重解。
///
/// 这是「截图被覆盖后仍显示旧缩略图」那个 bug 的唯一自动化抓手:把 mtime 从缓存键里去掉,
/// 本用例立刻红(`calls` 停在 1)。大小故意保持不变——只改 mtime,才能把「大小也在键里」这条
/// 排除掉。
#[test]
fn a_changed_file_is_decoded_again() {
clear_cache();
let dir = temp_dir("mtime");
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
let mut first = [shot("2024-05-01_10.00.00.png", bytes, Some(1_000))];
attach_with(&dir.0, &mut first, &mut counting);
assert_eq!(calls.get(), 1);
// 同名文件被覆盖:**只有 mtime 变了**(大小相同)。
let mut second = [shot("2024-05-01_10.00.00.png", bytes, Some(2_000))];
attach_with(&dir.0, &mut second, &mut counting);
assert_eq!(calls.get(), 2, "mtime 变了就是另一张图,必须重解");
}
/// **判据 ⑦**:同名同刻但**大小变了**(例如用户替换了文件并保了 mtime)也必须重解。
///
/// 抓得住:缓存键只剩路径(`calls` 停在 1)。与用例 ⑥ 一起把三段键的两段独立钉住。
#[test]
fn a_changed_size_is_decoded_again() {
clear_cache();
let dir = temp_dir("size");
let bytes = write_png(&dir.0, "2024-05-01_10.00.00.png");
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
let mut first = [shot("2024-05-01_10.00.00.png", bytes, Some(5))];
attach_with(&dir.0, &mut first, &mut counting);
let mut second = [shot("2024-05-01_10.00.00.png", bytes + 1, Some(5))];
attach_with(&dir.0, &mut second, &mut counting);
assert_eq!(calls.get(), 2, "大小变了必须重解");
}
/// 空快照不碰缓存、不 panic(新实例的常态)。
#[test]
fn an_empty_list_is_a_no_op() {
clear_cache();
let dir = temp_dir("empty");
let mut views: Vec<ScreenshotView> = Vec::new();
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
attach_with(&dir.0, &mut views, &mut counting);
assert_eq!(calls.get(), 0);
}
/// 缓存有界:**策略函数单独测**(不往全局缓存里灌 240 条——那会把并行用例的键清掉)。
///
/// 抓得住:淘汰那段被删掉(250 条 → 还是 250 条);
/// 抓不住:淘汰顺序(这里只留「本拍碰到的」,没有 LRU 语义,也不该有)。
#[test]
fn the_cache_stays_bounded() {
let mut store: Cache = HashMap::new();
let touched: Key = ("/x/keep.png".to_string(), Some(1), 10);
for round in 0..(MAX_CACHE_ENTRIES + 10) {
store.insert(
(format!("/x/{round}.png"), Some(round as u64), 10),
Ok(sample()),
);
}
store.insert(touched.clone(), Ok(sample()));
assert!(store.len() > MAX_CACHE_ENTRIES);
prune(&mut store, std::slice::from_ref(&touched));
assert_eq!(
store.len(),
1,
"超上限之后只该留这一拍碰到的键(实际 {})",
store.len()
);
assert!(store.contains_key(&touched), "本拍碰到的键被自己清了");
// 没超上限时一条都不许掉。
let mut small: Cache = HashMap::new();
small.insert(touched.clone(), Ok(sample()));
prune(&mut small, std::slice::from_ref(&touched));
assert_eq!(small.len(), 1);
}
/// 一张可复用的小缩略图(`tests` 里只用来占位,不看内容)。
fn sample() -> ThumbnailView {
ThumbnailView {
width: 1,
height: 1,
pixels: vec![0, 0, 0, 255],
}
}