Files
Unlucky-Launcher/crates/app-iced/src/pages/instances/saves.rs
T
AskaEth f217d83680 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 解析(世界名暂时就是目录名)。
2026-11-11 06:00:27 +08:00

381 lines
14 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):存档 / 截图的**只读**列表 + 打开文件夹。
//!
//! # 位置与形状
//!
//! 挂在详情列里、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),
)
}