feat(saves): 截图缩略图墙(M251 · D54 全套:解码/缓存/上限/可见失败)

按 D54 落地,解码在 palette-extract、缓存在 presenter、像素随快照走、壳层只画。

palette-extract(上一提交)→ presenter:
- dto/saves/preview.rs:ScreenshotPreview{NotGenerated,Ready,Failed} + ThumbnailView
  (带 is_consistent:长度 ≠ w×h×4 的像素不许进渲染器)+ ScreenshotThumbKind;
  ScreenshotView 新增 preview 字段(像素不落盘,随 IPC 面走);
- host/saves/thumbs.rs:attach() 在后台线程(spawn_list 同一条线程)按 mtime 倒序解前
  60 张、单张 >8 MiB 先查大小不进解码器、失败也逐条带原因;进程级 memo 键 =
  (路径, mtime, 大小),覆盖/替换都会重解;超过 240 条只留本拍碰到的键;
- presenter 新增依赖 unlucky-palette-extract(塔已批,方向 presenter → palette-extract)。

app-iced:
- saves.rs 拆成 model / rows / wall(原文件 385 行,加完墙必破 400):model 里建一次
  iced Handle(渲染回调零解码、零重复上传),wall 3 列排格子,没像素的格子给
  160×90 占位框 + 逐条原因;text.rs 拆出 text/saves.rs 承载存档/截图一族文案;
- i18n 加 4 条「这一格为什么没有图」(未生成/太大/读不出/解不出)+ 金表同步;
- 出图夹具(saves_shot)合成两种场景的像素,并摆出四种格子(真图×2/未生成/超限失败)。

用例:presenter 9 条(含缓存命中不重解、mtime 变了必重解、第 61 张不解、
>8MiB 不进解码器、缓存有界)+ app-iced 3 条(三种格子的副文案、坏像素降级)。
This commit is contained in:
AskaEth committed 2026-11-11 16:33:56 +08:00
1 parent 798bfb279b
commit 843f6d08f9
21 files changed
+1516 -357

No files matched your search

Generated
+1
View File
@@ -5213,6 +5213,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"unlucky-core", "unlucky-core",
"unlucky-palette-extract",
] ]
[[package]] [[package]]
+91 -5
View File
@@ -46,17 +46,23 @@ const OLDEST: u64 = NEWEST - 42 * 24 * 3_600_000;
/// 其中一个世界/一张截图的 mtime 是 `None`:那是「时间未知」这条分支的唯一出图证据。 /// 其中一个世界/一张截图的 mtime 是 `None`:那是「时间未知」这条分支的唯一出图证据。
#[must_use] #[must_use]
pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView { 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 { let world = |dir: &str, icon: bool, modified_ms: Option<u64>| WorldView {
dir: dir.to_string(), dir: dir.to_string(),
icon, icon,
modified_ms, modified_ms,
}; };
let shot = |file: &str, bytes: u64, modified_ms: Option<u64>| ScreenshotView { let shot = |file: &str, bytes: u64, modified_ms: Option<u64>, preview: ScreenshotPreview| {
ScreenshotView {
file: file.to_string(), file: file.to_string(),
bytes, bytes,
modified_ms, modified_ms,
preview,
}
}; };
SavesListView { 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_dir: "D:/ULL/.ULL/data/instances/Better-MC/screenshots".to_string(),
screenshots_exists: true, screenshots_exists: true,
// 四张截图,把缩略图墙的**四种格子**一次摆出来(M251):
// ① 白天(真像素)② 黄昏(真像素)③ 「未生成预览」占位 ④ 具名的失败原因。
// 像素是**合成的**(下面 `scene`):夹具不连真实解码器,出图只证明墙的排版与状态,
// 解码由 presenter 的 `host::saves::thumbs` 用例用真 PNG 钉住。
screenshots: vec![ screenshots: vec![
shot("2024-05-01_10.00.00.png", 1_887_436, Some(NEWEST)), shot(
shot("2024-04-30_21.14.05.png", 2_097_152, Some(MIDDLE)), "2024-05-01_10.00.00.png",
shot("2024-04-20_08.30.00.png", 964_608, None), 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(), skipped: Vec::new(),
// 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。 // 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。
@@ -165,3 +199,55 @@ pub fn seed(app: &mut crate::app::state::App, instance: &str) {
Mode::Plain | Mode::BackedUp => {} 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,
}
}
+25 -155
View File
@@ -51,6 +51,20 @@
//! //!
//! 写操作**成功**也有一句话([`Saves::notice`]):重命名 / 删除的结果列表上看得见,但备份的落点 //! 写操作**成功**也有一句话([`Saves::notice`]):重命名 / 删除的结果列表上看得见,但备份的落点
//! 看不见——「点了备份然后什么也没发生」是静默失败,所以回执里带上完整路径与字节数。 //! 看不见——「点了备份然后什么也没发生」是静默失败,所以回执里带上完整路径与字节数。
//!
//! # 截图区是**缩略图墙**(M251 · SAVESHOTS,D54)
//!
//! | 变化 | 落点 |
//! |---|---|
//! | 截图不再是一行字,而是「图 + 副文案」的格子 | [`wall`](3 列,见那个模块的文档) |
//! | 像素**不在**这里解,也不在渲染回调里解 | 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::widget::{column, container, row};
use iced::{Alignment, Element, Length}; use iced::{Alignment, Element, Length};
@@ -58,9 +72,6 @@ use iced::{Alignment, Element, Length};
use unlucky_design::color::Role; use unlucky_design::color::Role;
use unlucky_design::spacing::Space; use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole; 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::buttons::{self, Button};
use crate::app::components::interaction::Interaction; use crate::app::components::interaction::Interaction;
@@ -72,158 +83,18 @@ use super::forced_state;
use super::state::Message as PageMessage; use super::state::Message as PageMessage;
use super::Texts; use super::Texts;
mod model;
mod rows; mod rows;
/// 写操作成功那句话的产地(实现在 [`rows`] 里,页面与测试都从这一条路取)。 mod wall;
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; 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` 调它)。 /// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
pub(super) fn section<'a>( pub(super) fn section<'a>(
ui: &'a Ui, ui: &'a Ui,
@@ -293,9 +164,8 @@ pub(super) fn section<'a>(
if saves.screenshots.is_empty() { if saves.screenshots.is_empty() {
body = body.push(note(ui, texts.saves_no_screenshots())); body = body.push(note(ui, texts.saves_no_screenshots()));
} else { } else {
for item in &saves.screenshots { // 缩略图墙(M251):一行三格,没像素的格子给占位框 + 原因,不留白。
body = body.push(rows::world_plain(ui, item)); body = body.push(wall::grid(ui, &saves.screenshots));
}
} }
if saves.skipped > 0 { if saves.skipped > 0 {
body = body.push(note(ui, &saves.skipped_note)); body = body.push(note(ui, &saves.skipped_note));
@@ -0,0 +1,261 @@
//! 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 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(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
}
/// 一张截图 → 墙上的一格(缩略图句柄 / 占位 + 副文案)。
///
/// **三种状态各有各的话**(D54 第 6 条:失败逐条可见):
///
/// | 快照里的状态 | 框里 | 副文案 |
/// |---|---|---|
/// | `Ready`(像素自洽) | 真图 | `大小 · 时间` |
/// | `Ready`(像素长度不对) | 「解不出这张图」 | 同一句 + 原因(防御性:壳层不该拿到坏像素) |
/// | `NotGenerated` | 「未生成预览」 | 同一句(超预算没轮到) |
/// | `Failed` | 具名原因 | 原因 + OS/解码器原文(有的话) |
fn shot_cell(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,
// 一次上传:像素从这里搬进渲染器,之后每帧只借用同一个句柄。
preview: ShotPreview::Ready {
handle: Handle::from_rgba(thumb.width, thumb.height, thumb.pixels.clone()),
},
},
// 像素长度与宽高对不上:壳层不该拿到这种数据,但拿到了也不能画成花屏。
ScreenshotPreview::Ready(_) => {
missing(shot, texts.saves_preview_reason(ScreenshotThumbKind::Decode), "")
}
ScreenshotPreview::NotGenerated => missing(shot, texts.saves_preview_none(), ""),
ScreenshotPreview::Failed(failure) => missing(
shot,
texts.saves_preview_reason(failure.kind),
failure.detail.as_str(),
),
}
}
/// 「没有可画的像素」那一格:框里一句短话,格子下面把原因说全。
///
/// 短话与副文案**分开**是刻意的:`160×90` 的框里塞不下 OS 原文,而原文正是用户能自己动手的
/// 那一半信息(权限 / 被占用 / 文件没了),所以它去副文案那一行(会换行,不会丢)。
/// **不靠字符串切分**去拆两句:切分遇到「原因本身带括号」就会切错(这条踩过)。
fn missing(shot: &ScreenshotView, note: &str, detail: &str) -> Shot {
let caption = if detail.is_empty() {
note.to_string()
} else {
format!("{note}({detail})")
};
Shot {
name: shot.file.clone(),
detail: caption,
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`] | 一个世界:名字 + 副文案 +(空闲)重命名/备份/删除、(在飞)「正在处理…」、(改名中)输入框 + 改名/取消 |
//! | [`world_plain`] | 只读的一行(截图用它:截图不在三个写操作的范围里) | //! | [`world_detail`] / [`shot_detail`] | 两行副文案(`shot_detail` 现在喂缩略图墙的格子,见 [`super::wall`]) |
//! | [`world_detail`] / [`shot_detail`] | 两行副文案 | //!
//! M251:原来给截图用的只读行 [`world_plain`] **已删**——截图改画缩略图墙(`super::wall`),
//! 留着一个没人调的 `pub(super) fn` 只会在 `-D warnings` 下变成死代码告警。
//! //!
//! 三枚按钮与「打开存档文件夹」同一条口径:`buttons::text` + `frame::BASE`(M237:卡片里的控件 //! 三枚按钮与「打开存档文件夹」同一条口径:`buttons::text` + `frame::BASE`(M237:卡片里的控件
//! 把底传成卡底,hover/禁用状态才叠在卡实色上),出图取证时整行一起跟 `--force-state`。 //! 把底传成卡底,hover/禁用状态才叠在卡实色上),出图取证时整行一起跟 `--force-state`。
@@ -83,11 +85,6 @@ pub(super) fn world<'a>(
.into() .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>( fn rename_row<'a>(
ui: &'a Ui, ui: &'a Ui,
@@ -0,0 +1,120 @@
//! 截图**缩略图墙**(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`——它是"这里本该有一张图"的
//! 占位,尺寸要可预测,不能让一句话把它撑变形。
//!
//! ## 一行放几格
//!
//! [`PER_ROW`] = 3:`3 × 160 + 2 × 12 = 504px`。详情列在 1180 宽的窗口里内宽约 700,放 4 格
//! (676px)会贴边;窄窗口(<840 时详情整页替换)只剩约 500 内宽,3 格是**同时能站住两档**的
//! 那个数。改它之前先看 `DevDocs/artifacts/m251/` 的两张图。
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 = 3;
/// 整面墙(`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,17 @@ const ALLOWED: &[&str] = &[
]; ];
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。 /// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 9] = [ const PAGE_SOURCES: [(&str, &str); 12] = [
("instances.rs", include_str!("../../instances.rs")), ("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")), ("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")), ("instances/demo.rs", include_str!("../demo.rs")),
("instances/text.rs", include_str!("../text.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", "instances/launch_args.rs",
include_str!("../launch_args.rs"), include_str!("../launch_args.rs"),
@@ -68,6 +74,16 @@ const PAGE_SOURCES: [(&str, &str); 9] = [
("instances/saves.rs", include_str!("../saves.rs")), ("instances/saves.rs", include_str!("../saves.rs")),
// M247:SAVES 区的行与操作区(重命名 / 备份 / 删除 + 行内输入框) // M247:SAVES 区的行与操作区(重命名 / 备份 / 删除 + 行内输入框)
("instances/saves/rows.rs", include_str!("../saves/rows.rs")), ("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 单词的都必须在这张表里)。 /// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
@@ -319,25 +335,32 @@ fn every_english_literal_in_the_page_sources_is_whitelisted() {
"只扫到 {checked} 条带英文的字面量,扫描器可能失效了" "只扫到 {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!(source("instances.rs").contains("Texts::of("), "实例页没有从 Texts 取词");
assert!( assert!(
PAGE_SOURCES[0].1.contains("Texts::of("), source("instances.rs").contains("Header::new(texts.title())"),
"实例页没有从 Texts 取词"
);
assert!(
PAGE_SOURCES[0].1.contains("Header::new(texts.title())"),
"页头标题必须是词表里的那条(`Header::new` 里写死字符串 = 中文界面上顶着英文标题)" "页头标题必须是词表里的那条(`Header::new` 里写死字符串 = 中文界面上顶着英文标题)"
); );
assert!( assert!(
PAGE_SOURCES[3].1.contains("Key::Instances"), source("instances/text.rs").contains("Key::Instances"),
"text.rs 没有引用本族的 key" "text.rs 没有引用本族的 key"
); );
assert!( assert!(
PAGE_SOURCES[5].1.contains("Key::InstancesStage"), source("app/view/instances.rs").contains("Key::InstancesStage"),
"阶段标签没有走目录" "阶段标签没有走目录"
); );
assert!( assert!(
PAGE_SOURCES[4].1.contains("texts.launch_title()"), source("instances/launch_args.rs").contains("texts.launch_title()"),
"启动参数区没有走目录" "启动参数区没有走目录"
); );
} }
@@ -9,11 +9,12 @@
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
ScreenshotView, WorldView, ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ScreenshotView, ThumbnailView,
WorldView,
}; };
use unlucky_presenter::state::SavesState; use unlucky_presenter::state::SavesState;
use super::super::saves::{failure_line, from_state}; use super::super::saves::{failure_line, from_state, ShotPreview};
use super::super::Texts; use super::super::Texts;
use unlucky_i18n::Lang; use unlucky_i18n::Lang;
@@ -36,6 +37,12 @@ fn view(instance: &str) -> SavesListView {
file: "2024-05-01_10.00.00.png".to_string(), file: "2024-05-01_10.00.00.png".to_string(),
bytes: 2_048, bytes: 2_048,
modified_ms: None, modified_ms: None,
// M251:一像素的缩略图——这条用例只关心「格子/计数/副文案」的映射,不关心解码。
preview: ScreenshotPreview::Ready(ThumbnailView {
width: 1,
height: 1,
pixels: vec![0x2E, 0x7D, 0x4F, 0xFF],
}),
}], }],
skipped: vec![SavesSkipView { skipped: vec![SavesSkipView {
area: SavesArea::Screenshots, area: SavesArea::Screenshots,
@@ -79,6 +86,11 @@ fn the_snapshot_becomes_rows_with_stamps_and_badges() {
); );
assert_eq!(saves.screenshots.len(), 1); assert_eq!(saves.screenshots.len(), 1);
assert_eq!(
saves.thumbnails(),
1,
"带了像素的截图必须变成一格真图(不是占位)"
);
assert!( assert!(
saves.screenshots[0].detail.contains("2.0 KB"), saves.screenshots[0].detail.contains("2.0 KB"),
"截图的副文案要带大小:{:?}", "截图的副文案要带大小:{:?}",
@@ -243,3 +255,103 @@ fn millisecond_stamps_are_rendered_in_utc() {
} }
mod write; mod write;
/// **缩略图墙的三种格子**(M251 · D54 第 6 条):有像素 / 没轮到 / 具名失败,副文案各说各的。
///
/// 抓得住:①有像素却不画(`Ready` 被吞成占位);②「没生成」与「失败」被合并成同一句
/// (用户没法分辨"刷新一下可能就有了"与"得自己去修文件");③失败原因没上屏(原文丢掉)。
/// 抓不住:像素画得对不对(`Element` 没有内省接口,只能靠 `DevDocs/artifacts/m251/` 的图)。
#[test]
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()
}
);
// 失败的副文案要带 OS/解码器原文(只进括号),没生成的那格只要一句短话。
assert_eq!(
saves.screenshots[2].detail,
format!(
"{}(9437184 > 8388608 字节)",
texts.saves_preview_reason(ScreenshotThumbKind::TooLarge)
)
);
assert_eq!(saves.screenshots[1].detail, texts.saves_preview_none());
}
/// 像素长度与宽高对不上时**不许**建句柄(`Handle::from_rgba` 不校验长度 ⇒ 画出来是花屏)。
///
/// 抓得住:把 `is_consistent()` 那道判断删掉(这时会走 `Ready`,`thumbnails()` 变 1 而断言要 0)。
#[test]
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()
}
);
}
+4 -162
View File
@@ -26,7 +26,10 @@
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。 //! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{ModFailureKind, SavesFailureKind}; use unlucky_presenter::dto::ModFailureKind;
/// 存档 / 截图一族的文案(M235 读路径 + M247 写操作 + M251 缩略图)
mod saves;
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。 /// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[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 { fn text(self, key: Key) -> &'static str {
self.0.text(key) self.0.text(key)
} }
@@ -0,0 +1,191 @@
//! 实例页文本入口的**存档 / 截图一族**(从 `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` 存不存在(不解码,缩略图墙不在 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 装不下"), ("InstancesSavesReasonTooLarge", "the world is too large for one zip", "这个世界太大,一个 zip 装不下"),
("InstancesSavesReasonWrite", "the write failed", "写盘失败"), ("InstancesSavesReasonWrite", "the write failed", "写盘失败"),
("InstancesSavesReasonRunning", "the game is running (exit it first)", "游戏正在运行(先退出再操作)"), ("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`)。 /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
+8
View File
@@ -208,6 +208,14 @@ macro_rules! instances_entries {
// 实例在启动/运行中:三个写操作**主动不写盘**(不是"写盘失败"——Linux 上删一个正在跑的 // 实例在启动/运行中:三个写操作**主动不写盘**(不是"写盘失败"——Linux 上删一个正在跑的
// 世界是会成功的,所以这条要直说"先退出",别让用户以为是权限问题)。 // 世界是会成功的,所以这条要直说"先退出",别让用户以为是权限问题)。
InstancesSavesReasonRunning => ("the game is running (exit it first)", "游戏正在运行(先退出再操作)"), 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", "解不出这张图(可能已损坏或格式不支持)"),
} } } }
}; };
} }
+3 -2
View File
@@ -2,8 +2,9 @@
//! //!
//! ## 为什么落在本 crate //! ## 为什么落在本 crate
//! //!
//! D54 定的分层没有变:图片解码**只在本 crate**(`crates/design` 零第三方依赖的代价就是把解码 //! D54 定的分层没有变:图片解码**集中在 `palette-extract`**(`crates/design` 零第三方依赖的
//! 摊到这里)。截图是 PNG,本 crate 的 `image` 已经开了 `png`/`jpeg`/`bmp` 解码 feature, //! 代价就是把解码摊到这里;`app-iced` 另有一份 `image` 只用于截图编码与背景图,见它自己的
//! `Cargo.toml` 注释)。截图是 PNG,本 crate 的 `image` 已经开了 `png`/`jpeg`/`bmp` 解码 feature,
//! 所以加这条路**零新依赖、零新 crate、零新 feature**——只是把「解码 + 限尺寸」这段已有的能力 //! 所以加这条路**零新依赖、零新 crate、零新 feature**——只是把「解码 + 限尺寸」这段已有的能力
//! 换个目标边长([`THUMBNAIL_MAX_EDGE`])暴露一次。 //! 换个目标边长([`THUMBNAIL_MAX_EDGE`])暴露一次。
//! //!
+5
View File
@@ -24,6 +24,11 @@ serde_json.workspace = true
# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。 # host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。
unlucky-core = { path = "../core", version = "0.2.3" } 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] [lints]
workspace = true workspace = true
+3 -2
View File
@@ -86,10 +86,11 @@ pub use pack::{
pub use settings::{ pub use settings::{
find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION, find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION,
}; };
// M235: 实例存档 / 截图的只读列表 // M235: 实例存档 / 截图的只读列表(M251 加逐张缩略图预览)
pub use saves::{ pub use saves::{
SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView,
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotPreview, ScreenshotThumbFailure,
ScreenshotThumbKind, ScreenshotView, ThumbnailView, WorldView,
}; };
// M159: 自更新 // M159: 自更新
pub use update::{ pub use update::{
+13 -1
View File
@@ -39,6 +39,12 @@ use serde::{Deserialize, Serialize};
use crate::dto::InstanceId; use crate::dto::InstanceId;
mod preview;
pub use preview::{
ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ThumbnailView,
};
/// app → host 的存档指令。 /// app → host 的存档指令。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SavesCommand { pub enum SavesCommand {
@@ -135,7 +141,7 @@ pub struct WorldView {
pub modified_ms: Option<u64>, pub modified_ms: Option<u64>,
} }
/// 一张截图(**只给元数据**,不给像素:解码图片要引依赖,U3 明确不做)。 /// 一张截图(元数据 + **预览**)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScreenshotView { pub struct ScreenshotView {
/// 磁盘文件名(含扩展名)。 /// 磁盘文件名(含扩展名)。
@@ -144,6 +150,12 @@ pub struct ScreenshotView {
pub bytes: u64, pub bytes: u64,
/// 文件 mtime(UTC 毫秒;`None` = 取不到)。 /// 文件 mtime(UTC 毫秒;`None` = 取不到)。
pub modified_ms: Option<u64>, pub modified_ms: Option<u64>,
/// 预览(缩略图像素 / 没生成 / 具名失败原因),见 [`ScreenshotPreview`]。
///
/// 像素**不落盘**(D54 第 2 条):解码在 host 的后台线程里做完,随这份快照一次性交给
/// 壳层。`(file, modified_ms, bytes)` 同时也是 host 侧缓存键的三段——三者任一变了就是
/// 另一张图,必须重解。
pub preview: ScreenshotPreview,
} }
/// 被跳过的条目来自哪个目录。 /// 被跳过的条目来自哪个目录。
+89
View File
@@ -0,0 +1,89 @@
//! 一张截图的**预览**(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(), file: "2024-05-01_10.00.00.png".to_string(),
bytes: 1024, bytes: 1024,
modified_ms: None, 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 { skipped: vec![SavesSkipView {
area: SavesArea::Screenshots, area: SavesArea::Screenshots,
@@ -121,6 +127,66 @@ fn empty_means_no_worlds_and_no_screenshots() {
file: "a.png".to_string(), file: "a.png".to_string(),
bytes: 0, bytes: 0,
modified_ms: None, modified_ms: None,
preview: ScreenshotPreview::NotGenerated,
}); });
assert!(!view.is_empty()); 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::{ use crate::dto::{
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp,
SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotView, SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotPreview,
WorldView, ScreenshotView, WorldView,
}; };
use super::env::HostEnv; use super::env::HostEnv;
use super::job::{spawn, JobSender}; use super::job::{spawn, JobSender};
/// 截图的缩略图:后台解码 + 进程级 memo(D54 第 3/4/5 条,见模块文档)
pub mod thumbs;
mod write; mod write;
/// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。 /// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。
@@ -254,7 +257,23 @@ pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathB
} }
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。 /// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
///
/// **截图带缩略图**(M251):像素在这里(后台线程里)解好、随快照一起交出去,UI 只画。
/// D54 明写「不许在渲染回调里解码」——这条纪律的落点就是「`view` 里 attach,`view()` 只在
/// 后台线程被调」。解码失败/超限/超预算的每一张都带自己的状态,不留白(D54 第 6 条)。
fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesListView { 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 { SavesListView {
instance: instance.to_string(), instance: instance.to_string(),
dir: scan.dir.display().to_string(), dir: scan.dir.display().to_string(),
@@ -270,15 +289,7 @@ fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesLi
.collect(), .collect(),
screenshots_dir: scan.screenshots_dir.display().to_string(), screenshots_dir: scan.screenshots_dir.display().to_string(),
screenshots_exists: scan.screenshots_exists, screenshots_exists: scan.screenshots_exists,
screenshots: scan screenshots,
.screenshots
.iter()
.map(|shot| ScreenshotView {
file: shot.file.clone(),
bytes: shot.bytes,
modified_ms: shot.modified_ms,
})
.collect(),
skipped: scan skipped: scan
.skipped .skipped
.iter() .iter()
+175
View File
@@ -0,0 +1,175 @@
//! 截图的**后台解码 + 进程级 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;
/// 缓存的条目上限(超过就只留这一拍碰到的键)。
pub const MAX_CACHE_ENTRIES: usize = 240;
/// 缓存键:**(完整路径, 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()))
}
/// 给一批截图挂上预览(**在后台线程里调**,`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),
};
}
// ⑤ 有界:只留这一拍碰到的键(旧的一律掉)。
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,284 @@
//! `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) |
//!
//! 抓不住:缩略图**画质**(盒式采样的观感只能靠出图看);缓存淘汰策略的观感(`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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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);
}
/// 缓存有界:跑一轮超过 [`MAX_CACHE_ENTRIES`] 的键之后,缓存不会无限长。
///
/// 抓得住:淘汰那段被删掉(`store.len()` 会一路涨到 keys 数)。本用例只断言"有界",
/// **不**断言淘汰了哪一条(那是实现细节,钉死会让下一版换 LRU 时无谓地红)。
#[test]
fn the_cache_stays_bounded() {
let dir = temp_dir("bounded");
let bytes = write_png(&dir.0, "one.png");
let calls = std::cell::Cell::new(0u32);
let mut counting = |path: &Path, size: u64| {
calls.set(calls.get() + 1);
decode(path, size)
};
// 同一张图、每次一个新 mtime ⇒ 每次一个新键。
for round in 0..(MAX_CACHE_ENTRIES + 10) {
let mut views = [shot("one.png", bytes, Some(round as u64))];
attach_with(&dir.0, &mut views, &mut counting);
}
let store = cache().lock().unwrap_or_else(|poison| poison.into_inner());
assert!(
store.len() <= MAX_CACHE_ENTRIES,
"缓存长了 {} 条,上限是 {MAX_CACHE_ENTRIES}",
store.len()
);
}