按 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 条(三种格子的副文案、坏像素降级)。
256 lines
10 KiB
Rust
256 lines
10 KiB
Rust
//! 实例详情页的 **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),
|
||
)
|
||
}
|