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
@@ -71,6 +71,8 @@ mod boot_probe;
|
||||
mod construct;
|
||||
mod mods_shot;
|
||||
mod refresh;
|
||||
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
|
||||
mod saves_shot;
|
||||
mod theme;
|
||||
|
||||
/// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
|
||||
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
|
||||
|
||||
use unlucky_presenter::dto::{AppCommand, ModsCommand};
|
||||
use unlucky_presenter::dto::{AppCommand, ModsCommand, SavesCommand};
|
||||
|
||||
use super::mods_shot;
|
||||
use super::{mods_shot, saves_shot};
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::view;
|
||||
@@ -64,6 +64,8 @@ impl App {
|
||||
// **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态
|
||||
// (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。
|
||||
self.sync_mods();
|
||||
// M235:详情页 ⇄ SAVES 区的绑定与取数(换实例就重新列一次存档)。同样是**建模型之前**。
|
||||
self.sync_saves();
|
||||
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
|
||||
self.note_mods_failure();
|
||||
|
||||
@@ -155,6 +157,37 @@ impl App {
|
||||
.dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id))));
|
||||
}
|
||||
|
||||
/// 详情页 ⇄ SAVES 区的绑定与取数(M235)。
|
||||
///
|
||||
/// 判据与 `sync_mods` **一字不差**:绑定变了(进详情页 / 换实例 / 退出详情页)才动;
|
||||
/// 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Saves(List)` 去后台列目录。
|
||||
///
|
||||
/// 不弹 Toast:存档区的失败就地画在卡片里(带「重试」),再弹一条是同一件事说两遍。
|
||||
fn sync_saves(&mut self) {
|
||||
let wanted = match self.state.route() {
|
||||
Route::Detail(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if self.state.saves.bound == wanted {
|
||||
return;
|
||||
}
|
||||
self.state.bind_saves(wanted.as_deref());
|
||||
let Some(id) = wanted else {
|
||||
return;
|
||||
};
|
||||
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
|
||||
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
|
||||
if saves_shot::enabled() {
|
||||
self.state.saves.changed(saves_shot::view(&id));
|
||||
return;
|
||||
}
|
||||
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
|
||||
// 这里只是"进详情页顺手后台列一次目录",结果在下一拍心跳落地就够了(M224 同款理由:
|
||||
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红)。
|
||||
self.backend
|
||||
.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(id))));
|
||||
}
|
||||
|
||||
/// 启停失败的提示语:每条**新**失败弹一次 Toast。
|
||||
///
|
||||
/// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)。
|
||||
//!
|
||||
//! # 它解决什么
|
||||
//!
|
||||
//! D30 的 SAVES 区截图需要一屏**有内容的列表**(世界 + 截图)。产品路径的列表来自后台线程
|
||||
//! (列 `saves/` → 快照),而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及
|
||||
//! 完成,截出来会是「正在读取 saves/…」。M157 / M222 / M223 / M224 都是同一个问题,处理方式一致:
|
||||
//! **状态注入**,注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
|
||||
//!
|
||||
//! # 边界(不要把它读成产品能力)
|
||||
//!
|
||||
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
|
||||
//! - 它注入的是**快照**([`SavesListView`]),不是页面模型——`Saves::from_state` 的时间格式化、
|
||||
//! 计数、「有图标」标记走的都是产品代码;
|
||||
//! - 真实链路由端到端用例钉住:`core::game::saves::tests`(真盘只读列举)与
|
||||
//! `presenter::host::saves::tests`(派生运行目录 → 快照)。
|
||||
//!
|
||||
//! ```text
|
||||
//! ULL_SAVES_FIXTURE=1 ULL_INSTANCE_STATE=demo ULL_START_ROUTE=instances \
|
||||
//! unlucky-launcher-iced --screenshot=saves.png --software-renderer
|
||||
//! ```
|
||||
|
||||
/// 夹具开关是否打开(`ULL_SAVES_FIXTURE` 非空且不为 `0`)。
|
||||
#[must_use]
|
||||
pub fn enabled() -> bool {
|
||||
std::env::var("ULL_SAVES_FIXTURE")
|
||||
.map(|value| {
|
||||
let value = value.trim();
|
||||
!value.is_empty() && value != "0"
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 2024-05-01 10:00 UTC(写死的 UTC 毫秒,不是"现在")。
|
||||
///
|
||||
/// 写死有两个原因:两张主题图要能逐像素对照;而且它能证明「毫秒 → `YYYY-MM-DD HH:MM UTC`」
|
||||
/// 这条换算在页面上真的走通了(`docs` 里的期望值就是这个时刻)。
|
||||
const NEWEST: u64 = 1_714_557_600_000;
|
||||
/// 比 [`NEWEST`] 早 32 天。
|
||||
const MIDDLE: u64 = NEWEST - 32 * 24 * 3_600_000;
|
||||
/// 比 [`NEWEST`] 早 42 天。
|
||||
const OLDEST: u64 = NEWEST - 42 * 24 * 3_600_000;
|
||||
|
||||
/// 一份固定快照(实例 id 用调用方给的那个)。
|
||||
///
|
||||
/// 其中一个世界/一张截图的 mtime 是 `None`:那是「时间未知」这条分支的唯一出图证据。
|
||||
#[must_use]
|
||||
pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
|
||||
use unlucky_presenter::dto::{SavesListView, ScreenshotView, WorldView};
|
||||
|
||||
let world = |dir: &str, icon: bool, modified_ms: Option<u64>| WorldView {
|
||||
dir: dir.to_string(),
|
||||
icon,
|
||||
modified_ms,
|
||||
};
|
||||
let shot = |file: &str, bytes: u64, modified_ms: Option<u64>| ScreenshotView {
|
||||
file: file.to_string(),
|
||||
bytes,
|
||||
modified_ms,
|
||||
};
|
||||
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "D:/ULL/.ULL/data/instances/Better-MC/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![
|
||||
world("Better World", true, Some(NEWEST)),
|
||||
world("Redstone Lab", true, Some(MIDDLE)),
|
||||
world("Skyblock", false, Some(OLDEST)),
|
||||
world("Test Flat", false, None),
|
||||
],
|
||||
screenshots_dir: "D:/ULL/.ULL/data/instances/Better-MC/screenshots".to_string(),
|
||||
screenshots_exists: true,
|
||||
screenshots: vec![
|
||||
shot("2024-05-01_10.00.00.png", 1_887_436, Some(NEWEST)),
|
||||
shot("2024-04-30_21.14.05.png", 2_097_152, Some(MIDDLE)),
|
||||
shot("2024-04-20_08.30.00.png", 964_608, None),
|
||||
],
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand};
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
|
||||
|
||||
use crate::pages::InstanceMessage;
|
||||
|
||||
@@ -40,6 +40,22 @@ impl App {
|
||||
})));
|
||||
return Task::none();
|
||||
}
|
||||
// M235:重新列一次存档(失败后的「重试」也走这条)。
|
||||
//
|
||||
// 与 `SetModEnabled` 一样放在 `bound_to` 守卫**之前**:存档区只认"当前选中实例",
|
||||
// 与启动参数表单播没播种无关(那条守卫是给表单草稿用的)。
|
||||
InstanceMessage::ReloadSaves => {
|
||||
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(instance))));
|
||||
return Task::none();
|
||||
}
|
||||
// M235:打开存档文件夹——拉起哪个目录由 host 定(`saves/` → `screenshots/` → 运行目录),
|
||||
// 真正的 opener 在 `core::platform::opener`(平台差异不散到 app 这一层)。
|
||||
InstanceMessage::OpenSavesFolder => {
|
||||
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::OpenFolder {
|
||||
id: instance,
|
||||
})));
|
||||
return Task::none();
|
||||
}
|
||||
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
||||
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
|
||||
InstanceMessage::Jvm(text) => {
|
||||
|
||||
@@ -257,6 +257,7 @@ pub(super) fn model(
|
||||
logs: logs_model(state).with_status(lang),
|
||||
// M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段)
|
||||
mods: instances::mods_of(state, catalog),
|
||||
saves: instances::saves_of(state, catalog),
|
||||
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
|
||||
update: update_of(&state.update),
|
||||
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
|
||||
|
||||
@@ -25,7 +25,7 @@ use unlucky_presenter::dto::{
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::pages::{Detail, LaunchFacts, Meta, Mods};
|
||||
use crate::pages::{Detail, LaunchFacts, Meta, Mods, Saves};
|
||||
|
||||
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
|
||||
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||||
@@ -273,3 +273,11 @@ pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
|
||||
pub(super) fn mods_of(state: &AppState, catalog: Catalog) -> Mods {
|
||||
crate::pages::mods_from_state(&state.mods, catalog)
|
||||
}
|
||||
|
||||
/// 存档 / 截图的展示模型(M235):presenter 的视图态 → 页面要画的东西。
|
||||
///
|
||||
/// 与 [`mods_of`] 同一条接法:转换本体在 `pages::instances::saves::from_state`(文案要用
|
||||
/// `Texts`),这里只把"当前语言"给过去。
|
||||
pub(super) fn saves_of(state: &AppState, catalog: Catalog) -> Saves {
|
||||
crate::pages::saves_from_state(&state.saves, catalog)
|
||||
}
|
||||
@@ -51,6 +51,8 @@ mod detail;
|
||||
mod launch_args;
|
||||
// M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关)
|
||||
mod mods;
|
||||
// M235: 详情页的 SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
|
||||
mod saves;
|
||||
mod state;
|
||||
mod text;
|
||||
|
||||
@@ -61,6 +63,17 @@ mod tests;
|
||||
pub(crate) use data::{DEMO, NOTE, SELECTED};
|
||||
pub(crate) use demo::{demo, demo_form};
|
||||
pub use mods::{from_state as mods_from_state, Mods};
|
||||
pub use saves::{from_state as saves_from_state, Saves};
|
||||
|
||||
/// 存档区失败的一句话(app 层就地画红字用;取词入口仍是 [`Texts`])。
|
||||
///
|
||||
/// 本版**不弹 Toast**:只读列表的失败离用户的眼睛很近(就在卡片里),再弹一条是刷屏。
|
||||
pub fn saves_failure_line(
|
||||
failure: &unlucky_presenter::dto::SavesFailureView,
|
||||
catalog: unlucky_i18n::Catalog,
|
||||
) -> String {
|
||||
saves::failure_line(failure, Texts::of(catalog))
|
||||
}
|
||||
|
||||
/// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。
|
||||
pub fn mods_failure_line(
|
||||
@@ -142,16 +155,30 @@ pub fn view<'a>(
|
||||
metrics,
|
||||
list_column(ui, block, forced, texts),
|
||||
match &block.detail {
|
||||
Some(detail) => {
|
||||
detail::detail_column(ui, metrics, detail, form, &model.mods, forced, texts)
|
||||
}
|
||||
Some(detail) => detail::detail_column(
|
||||
ui,
|
||||
metrics,
|
||||
detail,
|
||||
form,
|
||||
&model.mods,
|
||||
&model.saves,
|
||||
forced,
|
||||
texts,
|
||||
),
|
||||
None => detail::detail_empty(ui, texts),
|
||||
},
|
||||
),
|
||||
_ => match &block.detail {
|
||||
Some(detail) => {
|
||||
detail::narrow_detail(ui, metrics, detail, form, &model.mods, forced, texts)
|
||||
}
|
||||
Some(detail) => detail::narrow_detail(
|
||||
ui,
|
||||
metrics,
|
||||
detail,
|
||||
form,
|
||||
&model.mods,
|
||||
&model.saves,
|
||||
forced,
|
||||
texts,
|
||||
),
|
||||
None => frame::body(metrics, detail::detail_empty(ui, texts)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ pub(super) fn detail_column<'a>(
|
||||
detail: &'a Detail,
|
||||
form: &'a LaunchForm,
|
||||
mods: &'a super::Mods,
|
||||
saves: &'a super::Saves,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -51,6 +52,9 @@ pub(super) fn detail_column<'a>(
|
||||
// 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。
|
||||
// 顺序因此是:身份(页头 + 元信息)→ 内容(MODS)→ 选项(启动参数)→ 动作。
|
||||
body = body.push(super::mods::section(ui, mods, forced, texts));
|
||||
// M235:SAVES 区紧跟 MODS 区(同为"内容"),理由与 MODS 一样:排在启动参数卡后面会落到
|
||||
// 折叠线以下——详情列是一个滚动区,而启动参数卡本身就有一屏高。
|
||||
body = body.push(super::saves::section(ui, saves, forced, texts));
|
||||
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
|
||||
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
|
||||
|
||||
@@ -69,6 +73,7 @@ pub(super) fn narrow_detail<'a>(
|
||||
detail: &'a Detail,
|
||||
form: &'a LaunchForm,
|
||||
mods: &'a super::Mods,
|
||||
saves: &'a super::Saves,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -93,6 +98,7 @@ pub(super) fn narrow_detail<'a>(
|
||||
body = push_text(ui, metrics, body, detail);
|
||||
// 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。
|
||||
body = body.push(super::mods::section(ui, mods, forced, texts));
|
||||
body = body.push(super::saves::section(ui, saves, forced, texts));
|
||||
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
|
||||
|
||||
body = body.push(frame::text_role(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -163,6 +163,13 @@ pub enum Message {
|
||||
/// 期望的启用状态
|
||||
enabled: bool,
|
||||
},
|
||||
/// 重新列一次存档(M235):读盘失败时的「重试」,也用于用户手动刷新。
|
||||
///
|
||||
/// 只发一条 `SavesCommand::List`——没有别的状态要复位(失败不清绑定,见 presenter 的
|
||||
/// `SavesState::failed`)。
|
||||
ReloadSaves,
|
||||
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
|
||||
OpenSavesFolder,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -7,6 +7,9 @@ mod i18n;
|
||||
/// M157 的门限判据(三态 chips 在版本不够时谁可点)。
|
||||
mod launch_args;
|
||||
|
||||
/// M235 的存档区模型判据(快照 → 行 / 计数 / 失败线 / UTC 时间)。
|
||||
mod saves;
|
||||
|
||||
use super::super::layout::source;
|
||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||||
|
||||
|
||||
@@ -29,14 +29,24 @@ use super::super::Texts;
|
||||
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
|
||||
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
|
||||
const ALLOWED: &[&str] = &[
|
||||
"Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB",
|
||||
"Java",
|
||||
"pid",
|
||||
"JVM",
|
||||
"OpenGL",
|
||||
"Vulkan",
|
||||
"MiB",
|
||||
// M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个
|
||||
// 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。
|
||||
"mods",
|
||||
// M235:`saves` / `screenshots` 同样是**目录名**(文件系统事实,玩家要照着去找)。
|
||||
// 「正在读取 saves/…」「还没有 saves/ 或 screenshots/ 目录」里的这两个词不许翻——
|
||||
// 翻成「存档目录」用户就对不上那两层文件夹了。
|
||||
"saves",
|
||||
"screenshots",
|
||||
];
|
||||
|
||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
const PAGE_SOURCES: [(&str, &str); 7] = [
|
||||
const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
("instances.rs", include_str!("../../instances.rs")),
|
||||
("instances/detail.rs", include_str!("../detail.rs")),
|
||||
("instances/demo.rs", include_str!("../demo.rs")),
|
||||
@@ -51,6 +61,8 @@ const PAGE_SOURCES: [(&str, &str); 7] = [
|
||||
),
|
||||
// M224:MODS 区(本地 mod 列表 + 启停开关)
|
||||
("instances/mods.rs", include_str!("../mods.rs")),
|
||||
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
|
||||
("instances/saves.rs", include_str!("../saves.rs")),
|
||||
];
|
||||
|
||||
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
|
||||
@@ -68,7 +80,7 @@ const PAGE_SOURCES: [(&str, &str); 7] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 28] = [
|
||||
const ALLOWED_LITERALS: [&str; 30] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
@@ -110,6 +122,9 @@ const ALLOWED_LITERALS: [&str; 28] = [
|
||||
"disabled",
|
||||
"count",
|
||||
"file",
|
||||
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
|
||||
"worlds",
|
||||
"shots",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! 「存档区」(M235)的**模型**判据:快照 → 页面要画的东西。
|
||||
//!
|
||||
//! 为什么只测一个纯函数:iced 0.14 的 `Element` 没有内省接口(与 `tests/launch_args.rs`
|
||||
//! 同一取舍),画出来的行在测试里拿不到。所以把"快照 → 行/计数/失败线"全部压在
|
||||
//! [`saves::from_state`] 里,在这里逐条钉住——页面那一支只负责把这些字段画出来。
|
||||
//!
|
||||
//! 守门形状:`detail.rs` 必须调 `saves::section(ui, saves, …)`(`instances/tests.rs` 的源码
|
||||
//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
|
||||
ScreenshotView, WorldView,
|
||||
};
|
||||
use unlucky_presenter::state::SavesState;
|
||||
|
||||
use super::super::saves::{failure_line, from_state};
|
||||
use super::super::Texts;
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
/// 2024-05-01 10:00 UTC(UTC 毫秒)。
|
||||
const STAMP: u64 = 1_714_557_600_000;
|
||||
|
||||
fn view(instance: &str) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "C:/mc/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "Better World".to_string(),
|
||||
icon: true,
|
||||
modified_ms: Some(STAMP),
|
||||
}],
|
||||
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||
screenshots_exists: true,
|
||||
screenshots: vec![ScreenshotView {
|
||||
file: "2024-05-01_10.00.00.png".to_string(),
|
||||
bytes: 2_048,
|
||||
modified_ms: None,
|
||||
}],
|
||||
skipped: vec![SavesSkipView {
|
||||
area: SavesArea::Screenshots,
|
||||
file: "notes.txt".to_string(),
|
||||
reason: SavesSkipKind::NotAnImage,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// 已绑定实例的视图态(`bind` 是 presenter 内部的 `pub(super)`,这里直接写字段——
|
||||
/// 它本来就是公开状态的一部分,与 `AppState::bind_saves` 走的是同一条路)。
|
||||
fn bound_state(instance: &str) -> SavesState {
|
||||
SavesState {
|
||||
bound: Some(instance.to_string()),
|
||||
..SavesState::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 快照 → 行:世界带「有图标」,截图带大小;时间**按 UTC 切**,取不到就说"时间未知"。
|
||||
#[test]
|
||||
fn the_snapshot_becomes_rows_with_stamps_and_badges() {
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
|
||||
assert!(!saves.loading);
|
||||
assert!(!saves.retryable, "成功的快照不该带重试");
|
||||
assert_eq!(saves.worlds.len(), 1);
|
||||
assert_eq!(saves.worlds[0].name, "Better World");
|
||||
let zh_icon = Texts::of(Lang::ZhCn.catalog()).saves_world_icon();
|
||||
assert!(
|
||||
saves.worlds[0].detail.contains("2024-05-01 10:00 UTC"),
|
||||
"世界的副文案要带 UTC 时间:{:?}",
|
||||
saves.worlds[0].detail
|
||||
);
|
||||
assert!(
|
||||
saves.worlds[0].detail.contains(zh_icon),
|
||||
"有 icon.png 的世界要带标记:{:?}",
|
||||
saves.worlds[0].detail
|
||||
);
|
||||
|
||||
assert_eq!(saves.screenshots.len(), 1);
|
||||
assert!(
|
||||
saves.screenshots[0].detail.contains("2.0 KB"),
|
||||
"截图的副文案要带大小:{:?}",
|
||||
saves.screenshots[0].detail
|
||||
);
|
||||
assert!(
|
||||
saves.screenshots[0]
|
||||
.detail
|
||||
.contains(Texts::of(Lang::ZhCn.catalog()).saves_time_unknown()),
|
||||
"mtime 缺失时要说『时间未知』,不许拿 0 或当前时间糊:{:?}",
|
||||
saves.screenshots[0].detail
|
||||
);
|
||||
|
||||
// 计数与跳过:3 个世界 / 12 张截图这种句子在这一层拼好。
|
||||
assert_eq!(saves.count, "1 个世界 · 1 张截图");
|
||||
assert_eq!(saves.skipped, 1);
|
||||
assert_eq!(saves.skipped_note, "跳过 1 项");
|
||||
}
|
||||
|
||||
/// 首帧(已绑定、还没有快照)是 `loading`;没有绑定则什么都不算"已加载"。
|
||||
#[test]
|
||||
fn a_bound_instance_without_a_snapshot_is_loading() {
|
||||
let state = bound_state("a");
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.loading);
|
||||
assert!(!saves.loaded());
|
||||
|
||||
let idle = from_state(&SavesState::default(), Lang::En.catalog());
|
||||
assert!(!idle.loading, "不在详情页不该画『正在读取』");
|
||||
assert!(!idle.loaded());
|
||||
}
|
||||
|
||||
/// 两个目录都不存在 ⇒ `missing`(画"还没有目录"那句,而不是"这里空空的")。
|
||||
/// 目录在但里面什么都没有 ⇒ `nothing_yet`。
|
||||
#[test]
|
||||
fn missing_directories_and_empty_ones_are_different_states() {
|
||||
let mut state = bound_state("a");
|
||||
let mut gone = view("a");
|
||||
gone.exists = false;
|
||||
gone.screenshots_exists = false;
|
||||
gone.worlds.clear();
|
||||
gone.screenshots.clear();
|
||||
gone.skipped.clear();
|
||||
state.changed(gone);
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.missing);
|
||||
assert!(!saves.nothing_yet(), "缺目录不该被当成『目录在但是空的』");
|
||||
|
||||
let mut state = bound_state("a");
|
||||
let mut empty = view("a");
|
||||
empty.worlds.clear();
|
||||
empty.screenshots.clear();
|
||||
empty.skipped.clear();
|
||||
state.changed(empty);
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(!saves.missing);
|
||||
assert!(saves.nothing_yet());
|
||||
}
|
||||
|
||||
/// 失败:读盘失败**可重试**,打开文件夹失败**不可重试**(重试按钮会去重新列目录,那是错的动作)。
|
||||
///
|
||||
/// 抓得住:把两种失败合成一条「重试」;也抓得住"失败顺手把列表清掉"(用户看不见上一次读到的东西)。
|
||||
#[test]
|
||||
fn only_list_failures_offer_a_retry() {
|
||||
for (kind, retryable) in [
|
||||
(SavesFailureKind::DirUnusable, true),
|
||||
(SavesFailureKind::InstanceUnavailable, true),
|
||||
(SavesFailureKind::OpenFailed, false),
|
||||
] {
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
kind,
|
||||
detail: "Access is denied.".to_string(),
|
||||
});
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(!saves.loading);
|
||||
assert_eq!(saves.retryable, retryable, "{kind:?} 的重试口径错了");
|
||||
assert!(saves.error.is_some(), "{kind:?} 没有画出失败");
|
||||
assert_eq!(saves.worlds.len(), 1, "{kind:?} 把旧列表清掉了");
|
||||
assert_eq!(saves.screenshots.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// **只拿到失败、一份快照都没有**时,不许被画成"这里还没有世界"。
|
||||
///
|
||||
/// 抓得住:`nothing_yet()` 只看"两个列表都空"——那样"读不了"会顶着两句"这里空空的",
|
||||
/// 用户会把权限问题读成"我没有存档"。
|
||||
#[test]
|
||||
fn a_failure_without_a_snapshot_is_not_an_empty_list() {
|
||||
let mut state = bound_state("a");
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
});
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.error.is_some());
|
||||
assert!(!saves.has_view, "没有快照却记成了有");
|
||||
assert!(!saves.nothing_yet(), "纯失败态被画成了空列表");
|
||||
assert_eq!(saves.count, "", "没有快照不该画计数");
|
||||
}
|
||||
|
||||
/// 失败主句跟着语言与大类走,OS 原文只进括号(M161 的文案纪律)。
|
||||
#[test]
|
||||
fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
};
|
||||
let zh = failure_line(&failure, Texts::of(Lang::ZhCn.catalog()));
|
||||
assert!(zh.starts_with("读取 saves/ 失败:"), "{zh}");
|
||||
assert!(zh.ends_with("(Access is denied.)"), "{zh}");
|
||||
|
||||
let en = failure_line(&failure, Texts::of(Lang::En.catalog()));
|
||||
assert!(en.starts_with("Could not read saves/:"), "{en}");
|
||||
assert!(en.contains("Access is denied."), "{en}");
|
||||
|
||||
// 没有原文时不留一对空括号。
|
||||
let bare = SavesFailureView {
|
||||
detail: String::new(),
|
||||
..failure
|
||||
};
|
||||
let text = failure_line(&bare, Texts::of(Lang::ZhCn.catalog()));
|
||||
assert!(!text.contains('('), "空原文留下了一对括号:{text}");
|
||||
}
|
||||
|
||||
/// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。
|
||||
#[test]
|
||||
fn millisecond_stamps_are_rendered_in_utc() {
|
||||
let stamp = |ms: u64| {
|
||||
let mut state = bound_state("a");
|
||||
let mut view = view("a");
|
||||
view.worlds[0].modified_ms = Some(ms);
|
||||
state.changed(view);
|
||||
from_state(&state, Lang::En.catalog()).worlds[0]
|
||||
.detail
|
||||
.clone()
|
||||
};
|
||||
// 1970-01-01 00:00 UTC(纪元)
|
||||
assert!(stamp(0).starts_with("1970-01-01 00:00 UTC"), "{}", stamp(0));
|
||||
// 2024-05-01 10:00 UTC
|
||||
assert!(
|
||||
stamp(STAMP).starts_with("2024-05-01 10:00 UTC"),
|
||||
"{}",
|
||||
stamp(STAMP)
|
||||
);
|
||||
// 2024-02-29 23:59 UTC(闰日)
|
||||
assert!(
|
||||
stamp(1_709_251_140_000).starts_with("2024-02-29 23:59 UTC"),
|
||||
"{}",
|
||||
stamp(1_709_251_140_000)
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::ModFailureKind;
|
||||
use unlucky_presenter::dto::{ModFailureKind, SavesFailureKind};
|
||||
|
||||
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -212,10 +212,85 @@ impl Texts {
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- 存档区(M235)
|
||||
pub(super) fn saves_title(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesTitle)
|
||||
}
|
||||
/// 「这一版只读」——U3 的边界固定画出来(见 `catalog/instances.rs` 的那段注释)。
|
||||
pub(super) fn saves_read_only(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesReadOnly)
|
||||
}
|
||||
/// 「3 个世界 · 12 张截图」。
|
||||
pub(super) fn saves_count(self, worlds: usize, shots: usize) -> String {
|
||||
self.0.render(
|
||||
Key::InstancesSavesCount,
|
||||
&[
|
||||
("worlds", &worlds.to_string()),
|
||||
("shots", &shots.to_string()),
|
||||
],
|
||||
)
|
||||
}
|
||||
pub(super) fn saves_loading(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesLoading)
|
||||
}
|
||||
pub(super) fn saves_empty(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesEmpty)
|
||||
}
|
||||
pub(super) fn saves_missing_dir(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesMissingDir)
|
||||
}
|
||||
pub(super) fn saves_worlds(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesWorlds)
|
||||
}
|
||||
pub(super) fn saves_screenshots(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesScreenshots)
|
||||
}
|
||||
pub(super) fn saves_no_worlds(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesNoWorlds)
|
||||
}
|
||||
pub(super) fn saves_no_screenshots(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesNoScreenshots)
|
||||
}
|
||||
/// 「有图标」:只判 `icon.png` 存不存在(不解码,缩略图墙不在 U3 范围内)。
|
||||
pub(super) fn saves_world_icon(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesWorldIcon)
|
||||
}
|
||||
pub(super) fn saves_time_unknown(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesTimeUnknown)
|
||||
}
|
||||
/// 「跳过 3 项」。
|
||||
pub(super) fn saves_skipped(self, count: usize) -> String {
|
||||
self.0
|
||||
.render(Key::InstancesSavesSkipped, &[("count", &count.to_string())])
|
||||
}
|
||||
pub(super) fn saves_open_folder(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesOpenFolder)
|
||||
}
|
||||
pub(super) fn saves_retry(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesRetry)
|
||||
}
|
||||
/// 读目录失败的主句:`读取 saves/ 失败:目录不可用`。
|
||||
pub(super) fn saves_failed(self, reason: &str) -> String {
|
||||
self.0
|
||||
.render(Key::InstancesSavesFailed, &[("reason", reason)])
|
||||
}
|
||||
/// 打开文件夹失败的主句:`打开文件夹失败:文件管理器没起来`。
|
||||
pub(super) fn saves_open_failed(self, reason: &str) -> String {
|
||||
self.0
|
||||
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
|
||||
}
|
||||
/// 失败大类的短句(`SavesFailureKind` → 一句话)。
|
||||
pub(super) fn saves_reason(self, kind: SavesFailureKind) -> &'static str {
|
||||
self.text(match kind {
|
||||
SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir,
|
||||
SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance,
|
||||
SavesFailureKind::OpenFailed => Key::InstancesSavesReasonOpen,
|
||||
})
|
||||
}
|
||||
|
||||
fn text(self, key: Key) -> &'static str {
|
||||
self.0.text(key)
|
||||
}
|
||||
|
||||
/// 这一页的全部 key(守门测试的取样口:**从 `Key::ALL` 过滤出来**,不是手抄一份清单)。
|
||||
///
|
||||
/// 域判据是前缀 `Instances`——本族的 key 名是契约(`catalog/instances.rs` 里一条一条写着),
|
||||
|
||||
@@ -128,7 +128,10 @@ pub(crate) mod settings;
|
||||
mod tests;
|
||||
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
|
||||
pub(crate) use instances::demo_form as demo_launch_form;
|
||||
pub use instances::{mods_failure_line, mods_from_state, InstanceMessage, LaunchForm, Mods};
|
||||
pub use instances::{
|
||||
mods_failure_line, mods_from_state, saves_failure_line, saves_from_state, InstanceMessage,
|
||||
LaunchForm, Mods, Saves,
|
||||
};
|
||||
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
||||
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
|
||||
pub use message::{Action, Message, UpdateAction};
|
||||
|
||||
@@ -145,6 +145,11 @@ pub struct Model {
|
||||
/// 谁填:`app/view/instances.rs` 的 `mods_of`(presenter 的 `AppState::mods` → 展示字段)。
|
||||
/// 空态(不在详情页 / 还没请求)是 `Mods::default()`,页面不画这一区。
|
||||
pub mods: super::instances::Mods,
|
||||
/// 存档 / 截图(M235):详情页 SAVES 区的只读列表(见 [`super::instances::Saves`])。
|
||||
///
|
||||
/// 谁填:`app/view/instances.rs` 的 `saves_of`(presenter 的 `AppState::saves` → 展示字段)。
|
||||
/// 空态(不在详情页 / 还没请求)是 `Saves::default()`,页面照旧画这一次请求前的样子。
|
||||
pub saves: super::instances::Saves,
|
||||
/// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
|
||||
///
|
||||
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
|
||||
@@ -175,6 +180,7 @@ impl Model {
|
||||
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
|
||||
settings: Vec::new(),
|
||||
mods: super::instances::Mods::default(),
|
||||
saves: super::instances::Saves::default(),
|
||||
lang: Lang::En,
|
||||
forced: None,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user