diff --git a/crates/app-iced/src/app/state/saves_shot.rs b/crates/app-iced/src/app/state/saves_shot.rs index 7f53e6a..94ed75b 100644 --- a/crates/app-iced/src/app/state/saves_shot.rs +++ b/crates/app-iced/src/app/state/saves_shot.rs @@ -106,7 +106,7 @@ pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView { Some(OLDEST), ScreenshotPreview::Failed(ScreenshotThumbFailure { kind: ScreenshotThumbKind::TooLarge, - detail: "9437184 > 8388608 字节".to_string(), + detail: "9437184 > 8388608".to_string(), }), ), ], @@ -214,7 +214,12 @@ fn scene(tone: u8) -> unlucky_presenter::dto::ThumbnailView { // 地平线在 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]) + ( + [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]) }; @@ -228,12 +233,20 @@ fn scene(tone: u8) -> unlucky_presenter::dto::ThumbnailView { 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])) + ( + 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)) + ( + 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); diff --git a/crates/app-iced/src/pages/instances/saves.rs b/crates/app-iced/src/pages/instances/saves.rs index 36e1378..6812649 100644 --- a/crates/app-iced/src/pages/instances/saves.rs +++ b/crates/app-iced/src/pages/instances/saves.rs @@ -56,7 +56,7 @@ //! //! | 变化 | 落点 | //! |---|---| -//! | 截图不再是一行字,而是「图 + 副文案」的格子 | [`wall`](3 列,见那个模块的文档) | +//! | 截图不再是一行字,而是「图 + 副文案」的格子 | [`wall`](2 列 = 同列元信息卡的栅格,见那个模块的文档) | //! | 像素**不在**这里解,也不在渲染回调里解 | presenter 的后台线程(`host::saves::thumbs`)解好、随快照过来;本层只把 `Handle` 建一次(`model::from_state`) | //! | 没有像素的格子**不许留白** | 占位框 + 一句短话;副文案把原因说全(超 8 MiB / 读不出 / 解不出 / 超 60 张预算) | //! @@ -164,7 +164,7 @@ pub(super) fn section<'a>( if saves.screenshots.is_empty() { body = body.push(note(ui, texts.saves_no_screenshots())); } else { - // 缩略图墙(M251):一行三格,没像素的格子给占位框 + 原因,不留白。 + // 缩略图墙(M251):一行两格(详情列 ≤424 宽,三格装不下),没像素的格子给占位框 + 原因。 body = body.push(wall::grid(ui, &saves.screenshots)); } if saves.skipped > 0 { diff --git a/crates/app-iced/src/pages/instances/saves/model.rs b/crates/app-iced/src/pages/instances/saves/model.rs index ede0ce1..10d826c 100644 --- a/crates/app-iced/src/pages/instances/saves/model.rs +++ b/crates/app-iced/src/pages/instances/saves/model.rs @@ -211,12 +211,12 @@ fn shot_cell(shot: &ScreenshotView, texts: Texts) -> Shot { }, }, // 像素长度与宽高对不上:壳层不该拿到这种数据,但拿到了也不能画成花屏。 - ScreenshotPreview::Ready(_) => { - missing(shot, texts.saves_preview_reason(ScreenshotThumbKind::Decode), stamp) - } - ScreenshotPreview::NotGenerated => { - missing(shot, texts.saves_preview_none(), stamp) - } + 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) } @@ -225,7 +225,7 @@ fn shot_cell(shot: &ScreenshotView, texts: Texts) -> Shot { /// 「没有可画的像素」那一格:**框里说为什么**(短句),**格子下面照旧是大小与时间**。 /// -/// 为什么原因不进副文案:`160px` 宽的格子里,"原因 + OS 原文"会摊成三行,把墙变得比图还吵; +/// 为什么原因不进副文案:`160px` 宽的格子里,「原因 + OS 原文」会摊成三行,把墙变得比图还吵; /// 而 `ScreenshotThumbFailure::detail`(`9437184 > 8388608 字节` / `Access is denied.`)留在 /// DTO 里,是排障用的原文,不是给墙看的正文。D54 第 6 条要的是「逐条、结构化、不是静默」—— /// 那一句在框里,每条各说各的。 diff --git a/crates/app-iced/src/pages/instances/saves/wall.rs b/crates/app-iced/src/pages/instances/saves/wall.rs index 11ec47f..b8962e3 100644 --- a/crates/app-iced/src/pages/instances/saves/wall.rs +++ b/crates/app-iced/src/pages/instances/saves/wall.rs @@ -62,9 +62,7 @@ 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); + 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)) diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 6b25889..2f289a4 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -56,10 +56,7 @@ const PAGE_SOURCES: [(&str, &str); 12] = [ ("instances/text.rs", include_str!("../text.rs")), // M251:存档 / 截图一族从 text.rs 拆出(那边越过 400 行)——新文件必须一起扫, // 否则那四句缩略图文案会被当成「死 key」。 - ( - "instances/text/saves.rs", - include_str!("../text/saves.rs"), - ), + ("instances/text/saves.rs", include_str!("../text/saves.rs")), ( "instances/launch_args.rs", include_str!("../launch_args.rs"), @@ -80,10 +77,7 @@ const PAGE_SOURCES: [(&str, &str); 12] = [ include_str!("../saves/model.rs"), ), // M251:截图缩略图墙 - ( - "instances/saves/wall.rs", - include_str!("../saves/wall.rs"), - ), + ("instances/saves/wall.rs", include_str!("../saves/wall.rs")), ]; /// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。 @@ -346,7 +340,10 @@ fn every_english_literal_in_the_page_sources_is_whitelisted() { .map(|(_, source)| *source) .unwrap_or_else(|| panic!("PAGE_SOURCES 里没有 {name}:拆文件之后忘了登记?")) }; - assert!(source("instances.rs").contains("Texts::of("), "实例页没有从 Texts 取词"); + assert!( + source("instances.rs").contains("Texts::of("), + "实例页没有从 Texts 取词" + ); assert!( source("instances.rs").contains("Header::new(texts.title())"), "页头标题必须是词表里的那条(`Header::new` 里写死字符串 = 中文界面上顶着英文标题)" diff --git a/crates/app-iced/src/pages/instances/text/saves.rs b/crates/app-iced/src/pages/instances/text/saves.rs index 2201abb..8c01969 100644 --- a/crates/app-iced/src/pages/instances/text/saves.rs +++ b/crates/app-iced/src/pages/instances/text/saves.rs @@ -143,7 +143,12 @@ impl Texts { 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 { + 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)], @@ -157,21 +162,33 @@ impl Texts { ) } /// 重命名失败的主句:`重命名 A 失败:同名世界已存在`。 - pub(in crate::pages::instances) fn saves_rename_failed(self, file: &str, reason: &str) -> String { + 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 { + 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 { + pub(in crate::pages::instances) fn saves_backup_failed( + self, + file: &str, + reason: &str, + ) -> String { self.0.render( Key::InstancesSavesBackupFailed, &[("file", file), ("reason", reason)], @@ -179,7 +196,10 @@ impl Texts { } // ------------------------------------------------- 截图缩略图(M251 · SAVESHOTS) /// 「这一格为什么没有图」的短句(`未生成预览` / 太大 / 读不出 / 解不出)。 - pub(in crate::pages::instances) fn saves_preview_reason(self, kind: ScreenshotThumbKind) -> &'static str { + 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, diff --git a/crates/palette-extract/src/tests.rs b/crates/palette-extract/src/tests.rs index 8049b8c..d4953d2 100644 --- a/crates/palette-extract/src/tests.rs +++ b/crates/palette-extract/src/tests.rs @@ -204,7 +204,8 @@ 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 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"); diff --git a/crates/presenter/src/dto/saves.rs b/crates/presenter/src/dto/saves.rs index eef3f0d..8bd74c8 100644 --- a/crates/presenter/src/dto/saves.rs +++ b/crates/presenter/src/dto/saves.rs @@ -41,9 +41,7 @@ use crate::dto::InstanceId; mod preview; -pub use preview::{ - ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ThumbnailView, -}; +pub use preview::{ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ThumbnailView}; /// app → host 的存档指令。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -135,7 +133,10 @@ 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, diff --git a/crates/presenter/src/dto/saves/preview.rs b/crates/presenter/src/dto/saves/preview.rs index 9d7a8b8..8c37e2e 100644 --- a/crates/presenter/src/dto/saves/preview.rs +++ b/crates/presenter/src/dto/saves/preview.rs @@ -86,4 +86,3 @@ pub enum ScreenshotThumbKind { /// 读到了但解不出来(坏 PNG / 不支持的格式)。 Decode, } - diff --git a/crates/presenter/src/host/saves/thumbs.rs b/crates/presenter/src/host/saves/thumbs.rs index 2f663b5..e0bf71c 100644 --- a/crates/presenter/src/host/saves/thumbs.rs +++ b/crates/presenter/src/host/saves/thumbs.rs @@ -118,7 +118,7 @@ fn attach_with( if bytes > MAX_SOURCE_BYTES { views[index].preview = ScreenshotPreview::Failed(ScreenshotThumbFailure { kind: ScreenshotThumbKind::TooLarge, - detail: format!("{bytes} > {MAX_SOURCE_BYTES} 字节"), + detail: format!("{bytes} > {MAX_SOURCE_BYTES}"), }); continue; } @@ -140,6 +140,15 @@ fn attach_with( }; } // ⑤ 有界:只留这一拍碰到的键(旧的一律掉)。 + 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)); } diff --git a/crates/presenter/src/host/saves/thumbs/tests.rs b/crates/presenter/src/host/saves/thumbs/tests.rs index 41f4308..e73a4d5 100644 --- a/crates/presenter/src/host/saves/thumbs/tests.rs +++ b/crates/presenter/src/host/saves/thumbs/tests.rs @@ -21,11 +21,11 @@ use super::*; 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, + 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)。 @@ -41,8 +41,7 @@ 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 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) @@ -73,7 +72,11 @@ fn shot(file: &str, bytes: u64, modified_ms: Option) -> ScreenshotView { 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 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); @@ -93,7 +96,9 @@ fn a_real_png_becomes_a_thumbnail() { 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 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| { @@ -165,7 +170,11 @@ fn the_sixty_first_screenshot_is_not_decoded() { }; attach_with(&dir.0, &mut views, &mut counting); - assert_eq!(calls.get(), MAX_PER_REFRESH as u32, "只该解前 {MAX_PER_REFRESH} 张"); + assert_eq!( + calls.get(), + MAX_PER_REFRESH as u32, + "只该解前 {MAX_PER_REFRESH} 张" + ); assert!( views[..MAX_PER_REFRESH] .iter() @@ -257,28 +266,44 @@ fn an_empty_list_is_a_no_op() { assert_eq!(calls.get(), 0); } -/// 缓存有界:跑一轮超过 [`MAX_CACHE_ENTRIES`] 的键之后,缓存不会无限长。 +/// 缓存有界:**策略函数单独测**(不往全局缓存里灌 240 条——那会把并行用例的键清掉)。 /// -/// 抓得住:淘汰那段被删掉(`store.len()` 会一路涨到 keys 数)。本用例只断言"有界", -/// **不**断言淘汰了哪一条(那是实现细节,钉死会让下一版换 LRU 时无谓地红)。 +/// 抓得住:淘汰那段被删掉(250 条 → 还是 250 条); +/// 抓不住:淘汰顺序(这里只留「本拍碰到的」,没有 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 ⇒ 每次一个新键。 + 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) { - let mut views = [shot("one.png", bytes, Some(round as u64))]; - attach_with(&dir.0, &mut views, &mut counting); + store.insert( + (format!("/x/{round}.png"), Some(round as u64), 10), + Ok(sample()), + ); } - let store = cache().lock().unwrap_or_else(|poison| poison.into_inner()); - assert!( - store.len() <= MAX_CACHE_ENTRIES, - "缓存长了 {} 条,上限是 {MAX_CACHE_ENTRIES}", + 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], + } }