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