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:
1 parent
798bfb279b
commit
843f6d08f9
21 files changed
+1516
-357
No files matched your search
Generated
+1
@@ -5213,6 +5213,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"unlucky-core",
|
||||
"unlucky-palette-extract",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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 {
|
||||
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,55 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,20 @@
|
||||
//!
|
||||
//! 写操作**成功**也有一句话([`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::{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,18 @@ use super::forced_state;
|
||||
use super::state::Message as PageMessage;
|
||||
use super::Texts;
|
||||
|
||||
mod model;
|
||||
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;
|
||||
|
||||
/// 列表里的一行(一个世界 / 一张截图)。
|
||||
#[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 +164,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):一行三格,没像素的格子给占位框 + 原因,不留白。
|
||||
body = body.push(wall::grid(ui, &saves.screenshots));
|
||||
}
|
||||
if saves.skipped > 0 {
|
||||
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_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,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 扫描与旧串扫描共用)。
|
||||
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 +74,16 @@ 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 +335,32 @@ 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!(source("instances.rs").contains("Texts::of("), "实例页没有从 Texts 取词");
|
||||
assert!(
|
||||
PAGE_SOURCES[0].1.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,11 +9,12 @@
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
|
||||
ScreenshotView, WorldView,
|
||||
ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ScreenshotView, ThumbnailView,
|
||||
WorldView,
|
||||
};
|
||||
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 unlucky_i18n::Lang;
|
||||
|
||||
@@ -36,6 +37,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 +86,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"),
|
||||
"截图的副文案要带大小:{:?}",
|
||||
@@ -243,3 +255,103 @@ fn millisecond_stamps_are_rendered_in_utc() {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -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`)。
|
||||
|
||||
@@ -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", "解不出这张图(可能已损坏或格式不支持)"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//!
|
||||
//! ## 为什么落在本 crate
|
||||
//!
|
||||
//! D54 定的分层没有变:图片解码**只在本 crate**(`crates/design` 零第三方依赖的代价就是把解码
|
||||
//! 摊到这里)。截图是 PNG,本 crate 的 `image` 已经开了 `png`/`jpeg`/`bmp` 解码 feature,
|
||||
//! D54 定的分层没有变:图片解码**集中在 `palette-extract`**(`crates/design` 零第三方依赖的
|
||||
//! 代价就是把解码摊到这里;`app-iced` 另有一份 `image` 只用于截图编码与背景图,见它自己的
|
||||
//! `Cargo.toml` 注释)。截图是 PNG,本 crate 的 `image` 已经开了 `png`/`jpeg`/`bmp` 解码 feature,
|
||||
//! 所以加这条路**零新依赖、零新 crate、零新 feature**——只是把「解码 + 限尺寸」这段已有的能力
|
||||
//! 换个目标边长([`THUMBNAIL_MAX_EDGE`])暴露一次。
|
||||
//!
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -39,6 +39,12 @@ 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 {
|
||||
@@ -135,7 +141,7 @@ pub struct WorldView {
|
||||
pub modified_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 一张截图(**只给元数据**,不给像素:解码图片要引依赖,U3 明确不做)。
|
||||
/// 一张截图(元数据 + **预览**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ScreenshotView {
|
||||
/// 磁盘文件名(含扩展名)。
|
||||
@@ -144,6 +150,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,
|
||||
}
|
||||
|
||||
/// 被跳过的条目来自哪个目录。
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user