feat(saves): 截图缩略图墙(M251 · D54 全套:解码/缓存/上限/可见失败)
按 D54 落地,解码在 palette-extract、缓存在 presenter、像素随快照走、壳层只画。
palette-extract(上一提交)→ presenter:
- dto/saves/preview.rs:ScreenshotPreview{NotGenerated,Ready,Failed} + ThumbnailView
(带 is_consistent:长度 ≠ w×h×4 的像素不许进渲染器)+ ScreenshotThumbKind;
ScreenshotView 新增 preview 字段(像素不落盘,随 IPC 面走);
- host/saves/thumbs.rs:attach() 在后台线程(spawn_list 同一条线程)按 mtime 倒序解前
60 张、单张 >8 MiB 先查大小不进解码器、失败也逐条带原因;进程级 memo 键 =
(路径, mtime, 大小),覆盖/替换都会重解;超过 240 条只留本拍碰到的键;
- presenter 新增依赖 unlucky-palette-extract(塔已批,方向 presenter → palette-extract)。
app-iced:
- saves.rs 拆成 model / rows / wall(原文件 385 行,加完墙必破 400):model 里建一次
iced Handle(渲染回调零解码、零重复上传),wall 3 列排格子,没像素的格子给
160×90 占位框 + 逐条原因;text.rs 拆出 text/saves.rs 承载存档/截图一族文案;
- i18n 加 4 条「这一格为什么没有图」(未生成/太大/读不出/解不出)+ 金表同步;
- 出图夹具(saves_shot)合成两种场景的像素,并摆出四种格子(真图×2/未生成/超限失败)。
用例:presenter 9 条(含缓存命中不重解、mtime 变了必重解、第 61 张不解、
>8MiB 不进解码器、缓存有界)+ app-iced 3 条(三种格子的副文案、坏像素降级)。
This commit is contained in:
1 parent
798bfb279b
commit
843f6d08f9
21 files changed
+1519
-360
No files matched your search
@@ -51,6 +51,20 @@
|
||||
//!
|
||||
//! 写操作**成功**也有一句话([`Saves::notice`]):重命名 / 删除的结果列表上看得见,但备份的落点
|
||||
//! 看不见——「点了备份然后什么也没发生」是静默失败,所以回执里带上完整路径与字节数。
|
||||
//!
|
||||
//! # 截图区是**缩略图墙**(M251 · SAVESHOTS,D54)
|
||||
//!
|
||||
//! | 变化 | 落点 |
|
||||
//! |---|---|
|
||||
//! | 截图不再是一行字,而是「图 + 副文案」的格子 | [`wall`](3 列,见那个模块的文档) |
|
||||
//! | 像素**不在**这里解,也不在渲染回调里解 | presenter 的后台线程(`host::saves::thumbs`)解好、随快照过来;本层只把 `Handle` 建一次(`model::from_state`) |
|
||||
//! | 没有像素的格子**不许留白** | 占位框 + 一句短话;副文案把原因说全(超 8 MiB / 读不出 / 解不出 / 超 60 张预算) |
|
||||
//!
|
||||
//! ## 为什么拆成三个文件
|
||||
//!
|
||||
//! 加完缩略图墙之后 `saves.rs` 会越过 400 行硬线:模型([`model`])、行与操作区([`rows`])、
|
||||
//! 墙([`wall`])各占一块,本文件只剩「卡片骨架 + 小标 + 空态 + 错误行」。
|
||||
//! 路径不变([`model`] 的东西用 `pub use` 转出),`view/instances.rs` 与用例的 import 一行不改。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
@@ -58,9 +72,6 @@ use iced::{Alignment, Element, Length};
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::Catalog;
|
||||
use unlucky_presenter::dto::{SavesFailureKind, SavesFailureOp, SavesFailureView};
|
||||
use unlucky_presenter::state::SavesState;
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::interaction::Interaction;
|
||||
@@ -72,158 +83,18 @@ use super::forced_state;
|
||||
use super::state::Message as PageMessage;
|
||||
use super::Texts;
|
||||
|
||||
mod model;
|
||||
mod rows;
|
||||
/// 写操作成功那句话的产地(实现在 [`rows`] 里,页面与测试都从这一条路取)。
|
||||
mod wall;
|
||||
|
||||
pub use model::{failure_line, from_state, Row, Saves, Shot, ShotPreview};
|
||||
/// 写操作成功那句话的产地(实现在 [`rows`] 里)。
|
||||
///
|
||||
/// 生产代码走 `model::from_state → rows::op_line`;这条 re-export 只给**用例**用
|
||||
/// (`tests/saves/write.rs` 从这里取),所以挂在 `cfg(test)` 下,免得非测试构建报未用导入。
|
||||
#[cfg(test)]
|
||||
pub(super) use rows::op_line;
|
||||
|
||||
/// 列表里的一行(一个世界 / 一张截图)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// 世界目录名或截图文件名(磁盘原样;写操作把它原样带回去当「源」)。
|
||||
pub name: String,
|
||||
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 正在行内重命名的那一行(来源是 presenter 的 `SavesState::draft`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Editing {
|
||||
/// 哪个世界。
|
||||
pub dir: String,
|
||||
/// 输入框里的内容(原样)。
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// SAVES 区的展示模型(`Model::saves`)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Saves {
|
||||
/// 这份数据属于哪个实例(`None` = 不在详情页 / 还没请求过)。
|
||||
pub bound: Option<String>,
|
||||
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
|
||||
pub loading: bool,
|
||||
/// 读盘/打开/写操作失败的人读原因(app 层按 i18n 拼好)。
|
||||
pub error: Option<String>,
|
||||
/// 这一条失败能不能靠「再列一次」解决(只有读盘失败算)。
|
||||
pub retryable: bool,
|
||||
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
|
||||
pub has_view: bool,
|
||||
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
|
||||
pub missing: bool,
|
||||
/// 世界行。
|
||||
pub worlds: Vec<Row>,
|
||||
/// 截图行(新的在前,顺序由 core 给)。
|
||||
pub screenshots: Vec<Row>,
|
||||
/// 计数行(`3 个世界 · 12 张截图`):**在这一层算好**,页面只借 `&str`。
|
||||
///
|
||||
/// 为什么不留到页面里 `format!`:iced 的 `Element` 只借这一帧的数据,页面函数返回的是
|
||||
/// **借用局部变量的树**,局部 `String` 一出栈就悬空(`E0515`)。与 [`super::mods::Mods`]
|
||||
/// 的 `count` 同一条做法。
|
||||
pub count: String,
|
||||
/// 跳过条目数(符号链接 / 非图片 / 读不出)。
|
||||
pub skipped: usize,
|
||||
/// 跳过提示(`跳过 3 项`)。
|
||||
pub skipped_note: String,
|
||||
/// 正在行内重命名的那一行(`None` = 没有在改名)。
|
||||
pub editing: Option<Editing>,
|
||||
/// 有写操作在飞的那个世界(`None` = 空闲)。
|
||||
pub busy: Option<String>,
|
||||
/// 上一次写操作成功的那句话(`None` = 这一拍没有回执)。
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
impl Saves {
|
||||
/// 有没有拿到一份快照(成功或失败都算;`loading` 中不算)。
|
||||
#[must_use]
|
||||
pub fn loaded(&self) -> bool {
|
||||
self.bound.is_some() && !self.loading
|
||||
}
|
||||
|
||||
/// 两个目录都在、但里面什么都没有(**必须先有一份快照**:纯失败态不是"这里空的")。
|
||||
#[must_use]
|
||||
pub fn nothing_yet(&self) -> bool {
|
||||
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
|
||||
}
|
||||
|
||||
/// 这个世界的写操作在飞吗(那一行画「正在处理…」并把按钮拿掉)。
|
||||
#[must_use]
|
||||
pub fn busy_with(&self, dir: &str) -> bool {
|
||||
self.busy.as_deref() == Some(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
|
||||
///
|
||||
/// 三条口径:
|
||||
///
|
||||
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
|
||||
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
|
||||
/// 2. **不做乐观更新**:行上的名字只来自快照,写操作的"在飞"只用来置灰那一行;
|
||||
/// 3. **回执只画一次**:`view.last_op` 是 host 写在快照里的,下一次普通 List 就没了。
|
||||
#[must_use]
|
||||
pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
||||
let texts = Texts::of(catalog);
|
||||
let mut out = Saves {
|
||||
bound: state.bound.clone(),
|
||||
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
|
||||
editing: state.draft.as_ref().map(|draft| Editing {
|
||||
dir: draft.dir.clone(),
|
||||
text: draft.text.clone(),
|
||||
}),
|
||||
busy: state.pending.as_ref().map(|op| op.dir().to_string()),
|
||||
..Saves::default()
|
||||
};
|
||||
if let Some(view) = &state.view {
|
||||
out.has_view = true;
|
||||
out.missing = !view.exists && !view.screenshots_exists;
|
||||
// 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍——
|
||||
// M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。
|
||||
out.worlds = view
|
||||
.worlds
|
||||
.iter()
|
||||
.map(|world| Row {
|
||||
name: world.dir.clone(),
|
||||
detail: rows::world_detail(world.icon, world.modified_ms, texts),
|
||||
})
|
||||
.collect();
|
||||
out.screenshots = view
|
||||
.screenshots
|
||||
.iter()
|
||||
.map(|shot| Row {
|
||||
name: shot.file.clone(),
|
||||
detail: rows::shot_detail(shot.bytes, shot.modified_ms, texts),
|
||||
})
|
||||
.collect();
|
||||
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
|
||||
out.skipped = view.skipped_count();
|
||||
out.skipped_note = texts.saves_skipped(out.skipped);
|
||||
out.notice = view.last_op.as_ref().map(|op| op_line(op, texts));
|
||||
}
|
||||
if let Some(failure) = &state.failed {
|
||||
out.loading = false;
|
||||
out.error = Some(failure_line(failure, texts));
|
||||
// 只有「读盘 / 列目录」失败才值得重试;写操作的重试就是再点一次那个按钮。
|
||||
out.retryable = failure.op.is_none() && failure.kind != SavesFailureKind::OpenFailed;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 失败的一句话(**唯一产地**:页面内红字用它,本版没有 Toast)。
|
||||
#[must_use]
|
||||
pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
|
||||
let reason = texts.saves_reason(failure.kind);
|
||||
let sentence = match (failure.op, failure.kind) {
|
||||
(Some(SavesFailureOp::Rename), _) => texts.saves_rename_failed(&failure.file, reason),
|
||||
(Some(SavesFailureOp::Delete), _) => texts.saves_delete_failed(&failure.file, reason),
|
||||
(Some(SavesFailureOp::Backup), _) => texts.saves_backup_failed(&failure.file, reason),
|
||||
(None, SavesFailureKind::OpenFailed) => texts.saves_open_failed(reason),
|
||||
(None, _) => texts.saves_failed(reason),
|
||||
};
|
||||
if failure.detail.is_empty() {
|
||||
return sentence;
|
||||
}
|
||||
format!("{sentence}({})", failure.detail)
|
||||
}
|
||||
|
||||
/// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
|
||||
pub(super) fn section<'a>(
|
||||
ui: &'a Ui,
|
||||
@@ -293,9 +164,8 @@ pub(super) fn section<'a>(
|
||||
if saves.screenshots.is_empty() {
|
||||
body = body.push(note(ui, texts.saves_no_screenshots()));
|
||||
} else {
|
||||
for item in &saves.screenshots {
|
||||
body = body.push(rows::world_plain(ui, item));
|
||||
}
|
||||
// 缩略图墙(M251):一行三格,没像素的格子给占位框 + 原因,不留白。
|
||||
body = body.push(wall::grid(ui, &saves.screenshots));
|
||||
}
|
||||
if saves.skipped > 0 {
|
||||
body = body.push(note(ui, &saves.skipped_note));
|
||||
|
||||
Reference in new issue
Block a user