feat(saves): 实例存档 / 截图的只读列表与打开文件夹(M235 STOREMVP)
用户诉求是 PCL 差距四项里的「存档截图 / 导入」,M230 审计确认三层全缺。塔裁决 U3
把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙(缩略图要引图片解码依赖,而
AGENTS §2 只允许 palette-extract 引 image 类依赖;截图/导入产物形态也没定型)。
core
- `game::saves`(新):只读列 `<run_dir>/saves`(世界目录)与 `<run_dir>/screenshots`
(图片文件)。只调 symlink_metadata / read_dir / metadata,不建目录、不写文件、不读
文件内容(`icon.png` 只判存在)。目录不存在 ⇒ 空列表不是错误;存在但不可用(符号
链接 / 不是目录 / 读不了)⇒ 具名错误。条目级异常逐条登记原因,不静默丢。
- `platform::opener`(新):`open_dir` 是**目录**打开器的唯一收敛点(explorer / open /
xdg-open),内部复用 `child::quiet`,cfg 分支只此一处。塔明确要求按 AGENTS §6 放
platform 而不是业务模块,并要一条可执行判据防退化
(`the_launcher_literals_live_here_only` 扫 `crates/core/src`:除本文件外不许出现
`"explorer"` / `"xdg-open"` 字面量)。
presenter
- `dto::saves`:SavesCommand(List / OpenFolder)、SavesListView、SavesFailureView
(结构 + OS 原文,不拼中文)、`SavesListView` 的计数助手。
- `host::saves`:走 `job::spawn`,UI 只收快照;运行目录仍是
`isolation::derive_game_dir` 的同一个答案(与启动 / mod 列表同源,**不写盘**)。
「打开文件夹」打开哪个目录由 host 定:`saves/` → `screenshots/` → 运行目录。
失败分三类(InstanceUnavailable / DirUnusable / OpenFailed),可重试。
- `state::saves`:快照整体替换、迟到结果丢弃、失败保留绑定(重试才有意义)。
- 接线:CoreEvent::Saves / AppCommand::Saves / HostTask::Saves。
app-iced
- `pages::instances::saves`:详情页 SAVES 卡片(MODS 之后、启动参数之前;**不套第二层
滚动条**,整列那一条是唯一入口)。世界行带 UTC 时间与「有图标」,截图行带大小与时间;
第一行固定画「这一版只读」(U3 边界让用户看得见)。读盘失败画一条红字 + 「重试」;
打开文件夹失败**不画**重试(那个动作的重试就是再点一次按钮)。
- 接线:Message::{ReloadSaves, OpenSavesFolder}、`sync_saves`(与 `sync_mods` 同一判据)、
Model::saves、`saves_shot` 出图夹具(ULL_SAVES_FIXTURE=1)。
i18n
- `InstancesSaves*` 20 条中英成对,catalog 与金表在**同一条锚点**后追加、逐字节一致。
证据
- core 只读判据:`game::saves::tests` 在真盘建世界/截图,调用前后各拍一次
(相对路径, 是否目录, 字节数, mtime)指纹并逐条比对;另有用例钉「目录缺失不建目录」。
- presenter:`host::saves::tests` 真盘端到端(custom 档位派生运行目录 → 快照)。
- GUI:`--software-renderer` 1180×760 浅/深各一张 + 1180×1280 两张「整块卡片可见」
(1180×760 下 SAVES 卡片位于折叠线附近,只能看到卡头;第二对用 ULL_WINDOW_SIZE
把窗口拉高,口径与 M98 的矩阵一致),落 `DevDocs/artifacts/M235/`。
没做的(U3 边界内,见 handoff):缩略图墙、导入、删除/重命名、`level.dat` 的
LevelName 解析(世界名暂时就是目录名)。
This commit is contained in:
1 parent
3789cb7e61
commit
f217d83680
36 files changed
+2702
-17
No files matched your search
@@ -0,0 +1,380 @@
|
||||
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP):存档 / 截图的**只读**列表 + 打开文件夹。
|
||||
//!
|
||||
//! # 位置与形状
|
||||
//!
|
||||
//! 挂在详情列里、MODS 区之后、启动参数卡**之前**(`pages/instances/detail.rs` 的 `detail_column`)。
|
||||
//! 与 MODS 区同一套卡片口径:[`frame::card`] + 一个 `LabelMedium` 小标 + 内容。
|
||||
//!
|
||||
//! **为什么在启动参数之前**:与 MODS 区同一个理由(M224 出图实测)——详情列整列是一个滚动区,
|
||||
//! 而启动参数卡本身就有一屏高;排在它后面等于这整块落在折叠线以下、根本看不见。顺序因此是:
|
||||
//! 身份(页头 + 元信息)→ 内容(MODS → SAVES)→ 选项(启动参数)→ 动作。
|
||||
//!
|
||||
//! # 为什么不自己再套一层滚动区
|
||||
//!
|
||||
//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条
|
||||
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
|
||||
//!
|
||||
//! # 只读(U3 的边界,页面固定画出来)
|
||||
//!
|
||||
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」:没有删除、没有重命名、没有导入、**没有缩略图**
|
||||
//! (缩略图要引图片解码依赖,`AGENTS.md` §2 不允许)。所以卡片里第一行永远画
|
||||
//! [`Texts::saves_read_only`]——用户点开就看见"这一版只读",不会以为漏了删除按钮。
|
||||
//!
|
||||
//! # 失败与重试
|
||||
//!
|
||||
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败不画重试**:
|
||||
//! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance
|
||||
//! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。
|
||||
|
||||
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 unlucky_i18n::Catalog;
|
||||
use unlucky_presenter::dto::{human_bytes, SavesFailureKind, SavesFailureView};
|
||||
use unlucky_presenter::state::SavesState;
|
||||
|
||||
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;
|
||||
|
||||
/// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// 世界目录名或截图文件名(磁盘原样)。
|
||||
pub name: String,
|
||||
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
|
||||
pub detail: 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,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
|
||||
///
|
||||
/// 两条口径:
|
||||
///
|
||||
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
|
||||
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
|
||||
/// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。
|
||||
#[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(),
|
||||
..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: world_detail(world.icon, world.modified_ms, texts),
|
||||
})
|
||||
.collect();
|
||||
out.screenshots = view
|
||||
.screenshots
|
||||
.iter()
|
||||
.map(|shot| Row {
|
||||
name: shot.file.clone(),
|
||||
detail: 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);
|
||||
}
|
||||
if let Some(failure) = &state.failed {
|
||||
out.loading = false;
|
||||
out.error = Some(failure_line(failure, texts));
|
||||
out.retryable = 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.kind {
|
||||
SavesFailureKind::OpenFailed => texts.saves_open_failed(reason),
|
||||
SavesFailureKind::DirUnusable | SavesFailureKind::InstanceUnavailable => {
|
||||
texts.saves_failed(reason)
|
||||
}
|
||||
};
|
||||
if failure.detail.is_empty() {
|
||||
return sentence;
|
||||
}
|
||||
format!("{sentence}({})", failure.detail)
|
||||
}
|
||||
|
||||
/// 世界那一行的副文案:`2024-05-01 10:00 UTC`,有图标时再挂一个「有图标」。
|
||||
fn world_detail(icon: bool, modified_ms: Option<u64>, texts: Texts) -> String {
|
||||
let stamp = stamp(modified_ms, texts);
|
||||
if icon {
|
||||
format!("{stamp} · {}", texts.saves_world_icon())
|
||||
} else {
|
||||
stamp
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图那一行的副文案:`1.2 MiB · 2024-05-01 10:00 UTC`。
|
||||
fn shot_detail(bytes: u64, modified_ms: Option<u64>, texts: Texts) -> String {
|
||||
format!("{} · {}", human_bytes(bytes), stamp(modified_ms, texts))
|
||||
}
|
||||
|
||||
/// mtime(UTC 毫秒)→ `2024-05-01 10:00 UTC`;取不到就说"时间未知"(不拿 0 或当前时间糊)。
|
||||
///
|
||||
/// 换算走 `core::launch::time` 的 RFC3339 格式化,再按 `app/view/instances.rs::last_played`
|
||||
/// 同一刀切开——**UTC 是 core 的口径,不做本地时区换算**(那属时间格式 mission)。
|
||||
fn stamp(modified_ms: Option<u64>, texts: Texts) -> String {
|
||||
let Some(text) = modified_ms.and_then(stamp_utc) else {
|
||||
return texts.saves_time_unknown().to_string();
|
||||
};
|
||||
text
|
||||
}
|
||||
|
||||
/// 毫秒 → `YYYY-MM-DD HH:MM UTC`(`None` = 早于 1970 / 溢出,不猜)。
|
||||
fn stamp_utc(ms: u64) -> Option<String> {
|
||||
let at = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms))?;
|
||||
let text = unlucky_core::launch::time::format_rfc3339_millis(at);
|
||||
// `2024-05-01T10:00:00.000Z` → `2024-05-01 10:00 UTC`(切两段,不解析、不重排)。
|
||||
// 模板与 `app/view/instances.rs::last_played` **一字不差**:同一句话只能有一份写法,
|
||||
// 页面源码的字面量白名单里也只有这一条(`instances/tests/i18n.rs` 的 `ALLOWED_LITERALS`)。
|
||||
let date = text.get(0..10)?;
|
||||
let time = text.get(11..16)?;
|
||||
Some(format!("{date} {time} UTC"))
|
||||
}
|
||||
|
||||
/// 画 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);
|
||||
// 只读边界固定画在第一行(见模块文档)。
|
||||
body = body.push(note(ui, texts.saves_read_only()));
|
||||
|
||||
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,
|
||||
),
|
||||
));
|
||||
}
|
||||
// 旧列表还在时照旧画出来(失败不清列表),用户能看见上一次读到的东西;
|
||||
// 一份快照都没有时到此为止——**不许**在"读不了"下面再补一句"这里还没有世界"。
|
||||
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);
|
||||
}
|
||||
|
||||
// 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。
|
||||
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(entry_row(ui, item));
|
||||
}
|
||||
}
|
||||
body = body.push(sub_head(ui, texts.saves_screenshots()));
|
||||
if saves.screenshots.is_empty() {
|
||||
body = body.push(note(ui, texts.saves_no_screenshots()));
|
||||
} else {
|
||||
for item in &saves.screenshots {
|
||||
body = body.push(entry_row(ui, item));
|
||||
}
|
||||
}
|
||||
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,
|
||||
),
|
||||
);
|
||||
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 entry_row<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> {
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
item.name.as_str(),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
item.detail.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn note<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user