fix(app-iced): 缩略图墙一行两格 + 副文案只留元数据(出图实测)

- 3 格装不下:详情列宽 = FrameMetrics::column ≤ 424(1180 窗宽),卡片内宽约 370,
  3×160+2×12=504 会把第 3 格挤成一条竖条(实测图上『未生成预览』竖着排)。改成 2 格
  = 332px,与同列元信息卡的两列栅格同宽;
- 原因只进占位框(短句),副文案仍画『大小 · 时间』:160px 里塞「原因 + OS 原文」会摊成
  三行,把墙变得比图还吵;原文留在 ScreenshotThumbFailure::detail 供排障;
- 出图:DevDocs/artifacts/m251/saves-light.png / saves-dark.png(1180×1400,
  --software-renderer --screenshot,ULL_SAVES_FIXTURE=1)。
This commit is contained in:
AskaEth committed 2026-11-11 16:40:41 +08:00
1 parent 843f6d08f9
commit 0b6b8f0a62
4 files changed
+45 -36

No files matched your search

@@ -196,9 +196,9 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
/// | 快照里的状态 | 框里 | 副文案 | /// | 快照里的状态 | 框里 | 副文案 |
/// |---|---|---| /// |---|---|---|
/// | `Ready`(像素自洽) | 真图 | `大小 · 时间` | /// | `Ready`(像素自洽) | 真图 | `大小 · 时间` |
/// | `Ready`(像素长度不对) | 「解不出这张图」 | 同一句 + 原因(防御性:壳层不该拿到坏像素) | /// | `Ready`(像素长度不对) | 「解不出这张图」 | `大小 · 时间`(防御性:壳层不该拿到坏像素) |
/// | `NotGenerated` | 「未生成预览」 | 同一句(超预算没轮到) | /// | `NotGenerated` | 「未生成预览」 | `大小 · 时间`(超预算没轮到) |
/// | `Failed` | 具名原因 | 原因 + OS/解码器原文(有的话) | /// | `Failed` | 具名原因(太大 / 读不出 / 解不出) | `大小 · 时间`(原文留在 DTO 里,不上墙) |
fn shot_cell(shot: &ScreenshotView, texts: Texts) -> Shot { fn shot_cell(shot: &ScreenshotView, texts: Texts) -> Shot {
let stamp = rows::shot_detail(shot.bytes, shot.modified_ms, texts); let stamp = rows::shot_detail(shot.bytes, shot.modified_ms, texts);
match &shot.preview { match &shot.preview {
@@ -212,31 +212,27 @@ fn shot_cell(shot: &ScreenshotView, texts: Texts) -> Shot {
}, },
// 像素长度与宽高对不上:壳层不该拿到这种数据,但拿到了也不能画成花屏。 // 像素长度与宽高对不上:壳层不该拿到这种数据,但拿到了也不能画成花屏。
ScreenshotPreview::Ready(_) => { ScreenshotPreview::Ready(_) => {
missing(shot, texts.saves_preview_reason(ScreenshotThumbKind::Decode), "") 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)
} }
ScreenshotPreview::NotGenerated => missing(shot, texts.saves_preview_none(), ""),
ScreenshotPreview::Failed(failure) => missing(
shot,
texts.saves_preview_reason(failure.kind),
failure.detail.as_str(),
),
} }
} }
/// 「没有可画的像素」那一格:框里一句短话,格子下面把原因说全。 /// 「没有可画的像素」那一格:**框里说为什么**(短句),**格子下面照旧是大小与时间**。
/// ///
/// 短话与副文案**分开**是刻意的:`160×90` 的框里塞不下 OS 原文,而原文正是用户能自己动手的 /// 为什么原因不进副文案:`160px` 宽的格子里,"原因 + OS 原文"会摊成三行,把墙变得比图还吵;
/// 那一半信息(权限 / 被占用 / 文件没了),所以它去副文案那一行(会换行,不会丢)。 /// 而 `ScreenshotThumbFailure::detail`(`9437184 > 8388608 字节` / `Access is denied.`)留在
/// **不靠字符串切分**去拆两句:切分遇到「原因本身带括号」就会切错(这条踩过)。 /// DTO 里,是排障用的原文,不是给墙看的正文。D54 第 6 条要的是「逐条、结构化、不是静默」——
fn missing(shot: &ScreenshotView, note: &str, detail: &str) -> Shot { /// 那一句在框里,每条各说各的。
let caption = if detail.is_empty() { fn missing(shot: &ScreenshotView, note: &str, stamp: String) -> Shot {
note.to_string()
} else {
format!("{note}({detail})")
};
Shot { Shot {
name: shot.file.clone(), name: shot.file.clone(),
detail: caption, detail: stamp,
preview: ShotPreview::Missing { preview: ShotPreview::Missing {
note: note.to_string(), note: note.to_string(),
}, },
@@ -23,11 +23,19 @@
//! 不拉伸(`ContentFit::Contain` 默认)。占位框反而钉死 `160×90`——它是"这里本该有一张图"的 //! 不拉伸(`ContentFit::Contain` 默认)。占位框反而钉死 `160×90`——它是"这里本该有一张图"的
//! 占位,尺寸要可预测,不能让一句话把它撑变形。 //! 占位,尺寸要可预测,不能让一句话把它撑变形。
//! //!
//! ## 一行放几格 //! ## 一行放几格(这条是**量出来的**,不是拍的)
//! //!
//! [`PER_ROW`] = 3:`3 × 160 + 2 × 12 = 504px`。详情列在 1180 宽的窗口里内宽约 700,放 4 格 //! 详情列的宽度由 `pages::layout` 的 `FrameMetrics::column` 定,而它 `≤ measure_cap_px()`
//! (676px)会贴边;窄窗口(<840 时详情整页替换)只剩约 500 内宽,3 格是**同时能站住两档**的 //! (1180 窗宽下 = 424,见 `layout.rs` 的文档)。卡片还有左右内边距 ⇒ 实际内宽约 **370**。
//! 那个数。改它之前先看 `DevDocs/artifacts/m251/` 的两张图。 //!
//! 于是:**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::widget::{column, container, image, row};
use iced::{Alignment, Background, Element, Length}; use iced::{Alignment, Background, Element, Length};
@@ -49,8 +57,8 @@ const THUMB_WIDTH: f32 = 160.0;
/// 占位框的高(16:9,与常见的截图宽高比一致)。 /// 占位框的高(16:9,与常见的截图宽高比一致)。
const THUMB_HEIGHT: f32 = 90.0; const THUMB_HEIGHT: f32 = 90.0;
/// 一行放几格(理由见模块文档)。 /// 一行放几格(= 同一列里元信息卡的两列栅格;理由见模块文档)。
const PER_ROW: usize = 3; const PER_ROW: usize = 2;
/// 整面墙(`saves.rs` 的 `section` 调;空列表不该走到这里)。 /// 整面墙(`saves.rs` 的 `section` 调;空列表不该走到这里)。
pub(super) fn grid<'a>(ui: &'a Ui, shots: &'a [Shot]) -> Element<'a, Message> { pub(super) fn grid<'a>(ui: &'a Ui, shots: &'a [Shot]) -> Element<'a, Message> {
@@ -315,15 +315,17 @@ fn every_preview_state_gets_its_own_caption() {
.to_string() .to_string()
} }
); );
// 失败的副文案要带 OS/解码器原文(只进括号),没生成的那格只要一句短话。 // 副文案三格一样是「大小 · 时间」:原因在框里,不把墙变得比图还吵(原文留在 DTO 里)。
assert_eq!( assert!(
saves.screenshots[2].detail, saves.screenshots[1].detail.starts_with("4.0 KB"),
format!( "没生成的那格也要有大小与时间:{:?}",
"{}(9437184 > 8388608 字节)", saves.screenshots[1].detail
texts.saves_preview_reason(ScreenshotThumbKind::TooLarge) );
) assert!(
saves.screenshots[2].detail.starts_with("9.0 MB"),
"失败的那格同样保留元数据:{:?}",
saves.screenshots[2].detail
); );
assert_eq!(saves.screenshots[1].detail, texts.saves_preview_none());
} }
/// 像素长度与宽高对不上时**不许**建句柄(`Handle::from_rgba` 不校验长度 ⇒ 画出来是花屏)。 /// 像素长度与宽高对不上时**不许**建句柄(`Handle::from_rgba` 不校验长度 ⇒ 画出来是花屏)。
@@ -49,7 +49,10 @@ impl Texts {
pub(in crate::pages::instances) fn saves_no_screenshots(self) -> &'static str { pub(in crate::pages::instances) fn saves_no_screenshots(self) -> &'static str {
self.text(Key::InstancesSavesNoScreenshots) self.text(Key::InstancesSavesNoScreenshots)
} }
/// 「有图标」:只判 `icon.png` 存不存在(不解码,缩略图墙不在 U3 范围内)。 /// 「有图标」:只判 `icon.png` 存不存在。
///
/// M251 之后**仍未解**世界图标——缩略图墙只覆盖 `screenshots/`(D54 的范围)。这行字眼
/// 原来写的是「缩略图墙不在 U3 范围内」,那句话今天已经失真(墙做了),改成现状描述。
pub(in crate::pages::instances) fn saves_world_icon(self) -> &'static str { pub(in crate::pages::instances) fn saves_world_icon(self) -> &'static str {
self.text(Key::InstancesSavesWorldIcon) self.text(Key::InstancesSavesWorldIcon)
} }