Files
Unlucky-Launcher/crates/app-iced/src/pages/instances/saves.rs
T
AskaEth 843f6d08f9 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 条(三种格子的副文案、坏像素降级)。
2026-11-11 16:33:56 +08:00

256 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP 读路径 + M247 · SAVESOPS 写操作):
//! 存档 / 截图列表 + 打开文件夹 + **每行的重命名 / 备份 / 删除**。
//!
//! # 位置与形状
//!
//! 挂在详情列里、MODS 区之后、启动参数卡**之前**(`pages/instances/detail.rs` 的 `detail_column`)。
//! 与 MODS 区同一套卡片口径:[`frame::card`] + 一个 `LabelMedium` 小标 + 内容。
//!
//! **为什么在启动参数之前**:与 MODS 区同一个理由(M224 出图实测)——详情列整列是一个滚动区,
//! 而启动参数卡本身就有一屏高;排在它后面等于这整块落在折叠线以下、根本看不见。顺序因此是:
//! 身份(页头 + 元信息)→ 内容(MODS → SAVES)→ 选项(启动参数)→ 动作。
//!
//! # 为什么不自己再套一层滚动区
//!
//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
//!
//! # 行操作区(M247)
//!
//! 每个**世界**行右侧三个文字按钮:**重命名 / 备份 / 删除**(截图行没有操作区:截图不在这三个
//! 写操作的范围里)。三档状态:
//!
//! | 状态 | 画什么 | 为什么 |
//! |---|---|---|
//! | 空闲 | 三个按钮 | — |
//! | 这一行的请求在飞 | 一句「正在处理…」顶替按钮 | 连点两次会发两条 rename/delete,第二条必然「世界已不在」 |
//! | 正在行内改名 | 输入框 + 「改名」/「取消」 | 见下 |
//!
//! **不做乐观回显**:行上的名字只来自快照,点完等 host 回一份新快照才变(与 MODS 区同一纪律)。
//!
//! # 重命名为什么是行内输入,不是模态输入框
//!
//! 现成的模态队列有 `DialogKind::Input`,但 app 侧的 `dialog::Message` 只有主/次按钮与关闭,
//! **没有「输入变了」这一条**——用输入模态等于重写一套可编辑模态。行内输入复用
//! `components::inputs` 的单行框(与启动参数区同一个控件),一处新控件都不加。
//! 输入缓冲住在 presenter 的 [`SavesState::draft`](页面模型每帧从快照重建,缓冲留在页面里会被
//! 每一次事件冲掉)。
//!
//! # 删除为什么必须二次确认
//!
//! 这是本页唯一**不可逆**的动作。点「删除」只发一条
//! [`PageMessage::RequestDeleteWorld`],真正的删除要等模态确认框的主按钮
//! (`app/update/dialog.rs` 里那一支)。确认框复用仓内现成的模态队列(`state.dialogs`),
//! 页面不新写弹窗。
//!
//! # 失败与重试
//!
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败与写操作
//! 失败不画重试**:那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的
//! affordance(三种失败共用 `detail`,靠大类与 `op` 分别,见 [`failure_line`])。
//!
//! 写操作**成功**也有一句话([`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};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::app::components::buttons::{self, Button};
use crate::app::components::interaction::Interaction;
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::forced_state;
use super::state::Message as PageMessage;
use super::Texts;
mod model;
mod 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;
/// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
pub(super) fn section<'a>(
ui: &'a Ui,
saves: &'a Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let mut body = column![head(ui, saves, forced, texts)]
.spacing(ui.space(Space::X3))
.width(Length::Fill);
if saves.loading {
body = body.push(note(ui, texts.saves_loading()));
return frame::card(ui, body);
}
if let Some(error) = saves.error.as_deref() {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
error,
ui.color(Role::Error),
));
if saves.retryable {
body = body.push(buttons::text(
ui,
forced_state(
Button::new(
texts.saves_retry(),
Message::Instances(PageMessage::ReloadSaves),
),
forced,
)
// M237:卡片里的控件把底传成卡底(hover / 禁用状态层叠卡实色)。
.base(frame::BASE),
));
}
// 旧列表还在时照旧画出来(失败不清列表),用户能看见上一次读到的东西;
// 一份快照都没有时到此为止——**不许**在"读不了"下面再补一句"这里还没有世界"。
if !saves.has_view {
return frame::card(ui, body);
}
}
if saves.missing {
body = body.push(note(ui, texts.saves_missing_dir()));
return frame::card(ui, body);
}
if saves.nothing_yet() {
body = body.push(note(ui, texts.saves_empty()));
return frame::card(ui, body);
}
// 写操作成功的回执(重命名 / 删除 / 备份)——放在列表**上面**:用户刚点的就是这里。
if let Some(notice) = saves.notice.as_deref() {
body = body.push(note(ui, notice));
}
// 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。
body = body.push(sub_head(ui, texts.saves_worlds()));
if saves.worlds.is_empty() {
body = body.push(note(ui, texts.saves_no_worlds()));
} else {
for item in &saves.worlds {
body = body.push(rows::world(ui, item, saves, forced, texts));
}
}
body = body.push(sub_head(ui, texts.saves_screenshots()));
if saves.screenshots.is_empty() {
body = body.push(note(ui, texts.saves_no_screenshots()));
} else {
// 缩略图墙(M251):一行三格,没像素的格子给占位框 + 原因,不留白。
body = body.push(wall::grid(ui, &saves.screenshots));
}
if saves.skipped > 0 {
body = body.push(note(ui, &saves.skipped_note));
}
frame::card(ui, body)
}
/// 小标行:`SAVES` + 计数 + 右侧「打开存档文件夹」。
fn head<'a>(
ui: &'a Ui,
saves: &'a Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
// 左半是「存档 + 计数」,右半是「打开存档文件夹」:左半用一个吃满宽度的容器顶开,
// 按钮才会落在卡片右边缘(与 `head` 之外那些卡片的动作行同一套摆法)。
let mut left = row![frame::text_role(
ui,
TypeRole::LabelMedium,
false,
texts.saves_title(),
ui.color(Role::OnSurfaceVariant),
)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
// 计数只在"真有一份可用快照"时画:读盘中 / 读失败 / 空目录画 0·0 会像"这里什么都没有"。
let show_count = saves.has_view
&& saves.error.is_none()
&& !saves.missing
&& !(saves.worlds.is_empty() && saves.screenshots.is_empty());
if show_count {
left = left.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
saves.count.as_str(),
ui.color(Role::OnSurfaceVariant),
));
}
// 「打开文件夹」始终可点:目录还没有时 host 会退到运行目录,失败也有就地说明
// (见 `host::saves::open_target`)。
let open = buttons::text(
ui,
forced_state(
Button::new(
texts.saves_open_folder(),
Message::Instances(PageMessage::OpenSavesFolder),
),
forced,
)
// M237:这枚按钮在 SAVES 卡里 ⇒ 状态层叠卡底(`frame::BASE`),不是画布底。
.base(frame::BASE),
);
row![
container(left)
.width(Length::Fill)
.align_y(Alignment::Center),
open
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
fn sub_head<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::LabelSmall,
false,
text,
ui.color(Role::OnSurfaceVariant),
)
}
fn note<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(Role::OnSurfaceVariant),
)
}