用户诉求是 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 解析(世界名暂时就是目录名)。
306 lines
13 KiB
Rust
306 lines
13 KiB
Rust
//! 实例页的**文案入口**:页面上一处可见英文字面量都不留,全部从目录取。
|
||
//!
|
||
//! ## 为什么要有这一层(而不是在控件树里直接 `catalog.text(..)`)
|
||
//!
|
||
//! 页面要的文案有 20 多条,直接写的话每个调用点都是「key 名 + 这一处画什么」的重复劳动,
|
||
//! 而且**守门测试没法取样**:iced 0.14 的 `Element` 没有内省接口(`instances/tests.rs` 里
|
||
//! 「读源码」那条注释解释过同一件事),所以「上屏文本」只能从**页面取词的那一个入口**拿。
|
||
//! 于是所有取词收进 [`Texts`],页面用 `texts.xxx()` 画,测试用同一个 `Texts` 断言——
|
||
//! 一处定义、两处消费,不存在「测试抄了一份平行实现」。
|
||
//!
|
||
//! ## 它抓得住什么 / 抓不住什么
|
||
//!
|
||
//! - **抓得住**:目录里某条中文忘了翻(「无 ASCII 英文单词」)、某条中英写成一样(「切换语言文本真的变」)、
|
||
//! `Running · pid {pid}` 的数字与分隔符被翻译走样、页面里写死**英文**字面量
|
||
//! (`every_english_literal_in_the_page_sources_is_whitelisted`,白名单判定);
|
||
//! - **抓不住**:页面里写死的**中文**字面量(没有 ASCII 词,扫不到)、`PAGE_SOURCES` 五个文件
|
||
//! 之外的东西(如 `data.rs` 夹具数据)、译文翻得顺不顺——这些只能靠出图人工看
|
||
//! (`DevDocs/artifacts/m120/`)。
|
||
//!
|
||
//! ## 口径
|
||
//!
|
||
//! 品牌名不翻译(`Java` / `Fabric` / `Forge` 原样);`pid` 是进程号的技术标识,两套语言都写
|
||
//! `pid`(改成「进程」会丢掉「拿它去任务管理器里搜」这条可操作性)。
|
||
//!
|
||
//! **启动阶段(下载中 / 校验中 / …)不在这张表里**:它在 `app/view/instances.rs` 拼进
|
||
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
|
||
|
||
use unlucky_i18n::{Catalog, Key};
|
||
use unlucky_presenter::dto::{ModFailureKind, SavesFailureKind};
|
||
|
||
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) struct Texts(Catalog);
|
||
|
||
impl Texts {
|
||
/// 从目录取一套(调用方给 `model.lang.catalog()`)。
|
||
pub(super) fn of(catalog: Catalog) -> Self {
|
||
Self(catalog)
|
||
}
|
||
|
||
// ------------------------------------------------------------- 页头
|
||
pub(super) fn title(self) -> &'static str {
|
||
self.text(Key::InstancesTitle)
|
||
}
|
||
pub(super) fn subtitle(self) -> &'static str {
|
||
self.text(Key::InstancesSubtitle)
|
||
}
|
||
pub(super) fn new_instance(self) -> &'static str {
|
||
self.text(Key::InstancesNew)
|
||
}
|
||
pub(super) fn import(self) -> &'static str {
|
||
self.text(Key::InstancesImport)
|
||
}
|
||
|
||
// --------------------------------------------------------------- 列表列
|
||
pub(super) fn all_title(self) -> &'static str {
|
||
self.text(Key::InstancesAllTitle)
|
||
}
|
||
|
||
// --------------------------------------------------------------- 详情列
|
||
pub(super) fn back(self) -> &'static str {
|
||
self.text(Key::InstancesBack)
|
||
}
|
||
pub(super) fn play(self) -> &'static str {
|
||
self.text(Key::InstancesPlay)
|
||
}
|
||
pub(super) fn stop(self) -> &'static str {
|
||
self.text(Key::InstancesStop)
|
||
}
|
||
pub(super) fn duplicate(self) -> &'static str {
|
||
self.text(Key::InstancesDuplicate)
|
||
}
|
||
pub(super) fn open_folder(self) -> &'static str {
|
||
self.text(Key::InstancesOpenFolder)
|
||
}
|
||
pub(super) fn delete(self) -> &'static str {
|
||
self.text(Key::InstancesDelete)
|
||
}
|
||
pub(super) fn empty_title(self) -> &'static str {
|
||
self.text(Key::InstancesEmptyTitle)
|
||
}
|
||
pub(super) fn empty_body(self) -> &'static str {
|
||
self.text(Key::InstancesEmptyBody)
|
||
}
|
||
pub(super) fn narrow_note(self) -> &'static str {
|
||
self.text(Key::InstancesNarrowNote)
|
||
}
|
||
|
||
// ------------------------------------------------------------- 启动参数(M157)
|
||
pub(super) fn launch_title(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchTitle)
|
||
}
|
||
pub(super) fn launch_jvm(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchJvm)
|
||
}
|
||
pub(super) fn launch_jvm_hint(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchJvmHint)
|
||
}
|
||
pub(super) fn launch_game(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchGame)
|
||
}
|
||
pub(super) fn launch_game_hint(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchGameHint)
|
||
}
|
||
pub(super) fn launch_memory(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchMemory)
|
||
}
|
||
/// 「留空 = 跟随全局默认({default} MiB)」,`default` 是生效值(三级回落之后)。
|
||
pub(super) fn launch_memory_hint(self, default_mb: u32) -> String {
|
||
self.0.render(
|
||
Key::InstancesLaunchMemoryHint,
|
||
&[("default", &default_mb.to_string())],
|
||
)
|
||
}
|
||
pub(super) fn launch_java(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchJava)
|
||
}
|
||
pub(super) fn launch_java_hint(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchJavaHint)
|
||
}
|
||
pub(super) fn launch_backend(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchBackend)
|
||
}
|
||
/// 「需游戏版本 {min} 及以上(本实例 {version})」。
|
||
///
|
||
/// `min` 来自 DTO(`LaunchFacts::min_version` ← core 的 `GRAPHICS_BACKEND_MIN_VERSION`),
|
||
/// **不在目录里硬编码版本号**——否则 core 一改门槛,文案会继续骗人(r4 复审 P3-c)。
|
||
pub(super) fn launch_backend_gated(self, version: &str, min: &str) -> String {
|
||
self.0.render(
|
||
Key::InstancesLaunchBackendGated,
|
||
&[("version", version), ("min", min)],
|
||
)
|
||
}
|
||
/// 「生效:vulkan」。
|
||
pub(super) fn launch_effective(self, value: &str) -> String {
|
||
self.0
|
||
.render(Key::InstancesLaunchEffective, &[("value", value)])
|
||
}
|
||
/// 三态后端的显示名(稳定 id → 文案);**未知 id 返回 `None`**(调用方原样上屏,不猜)。
|
||
pub(super) fn backend_label(self, id: &str) -> Option<&'static str> {
|
||
Some(self.text(match id {
|
||
"default" => Key::InstancesBackendDefault,
|
||
"opengl" => Key::InstancesBackendOpengl,
|
||
"vulkan" => Key::InstancesBackendVulkan,
|
||
_ => return None,
|
||
}))
|
||
}
|
||
pub(super) fn launch_save(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchSave)
|
||
}
|
||
pub(super) fn launch_reset(self) -> &'static str {
|
||
self.text(Key::InstancesLaunchReset)
|
||
}
|
||
|
||
// ------------------------------------------------------------- MODS 区(M224)
|
||
pub(super) fn mods_title(self) -> &'static str {
|
||
self.text(Key::InstancesModsTitle)
|
||
}
|
||
/// 「12 启用 · 3 禁用」。
|
||
pub(super) fn mods_count(self, enabled: usize, disabled: usize) -> String {
|
||
self.0.render(
|
||
Key::InstancesModsCount,
|
||
&[
|
||
("enabled", &enabled.to_string()),
|
||
("disabled", &disabled.to_string()),
|
||
],
|
||
)
|
||
}
|
||
pub(super) fn mods_loading(self) -> &'static str {
|
||
self.text(Key::InstancesModsLoading)
|
||
}
|
||
pub(super) fn mods_empty(self) -> &'static str {
|
||
self.text(Key::InstancesModsEmpty)
|
||
}
|
||
pub(super) fn mods_missing_dir(self) -> &'static str {
|
||
self.text(Key::InstancesModsMissingDir)
|
||
}
|
||
pub(super) fn mods_running(self) -> &'static str {
|
||
self.text(Key::InstancesModsRunning)
|
||
}
|
||
pub(super) fn mods_old_badge(self) -> &'static str {
|
||
self.text(Key::InstancesModsOldBadge)
|
||
}
|
||
/// 「跳过 3 项」。
|
||
pub(super) fn mods_skipped(self, count: usize) -> String {
|
||
self.0
|
||
.render(Key::InstancesModsSkipped, &[("count", &count.to_string())])
|
||
}
|
||
/// 读目录失败的主句:`读取 mods/ 失败:没有可用的 mods/ 目录`。
|
||
pub(super) fn mods_failed(self, reason: &str) -> String {
|
||
self.0
|
||
.render(Key::InstancesModsFailed, &[("reason", reason)])
|
||
}
|
||
/// 启停失败的主句:`启停 x.jar 失败:同名文件已存在`。
|
||
pub(super) fn mods_toggle_failed(self, file: &str, reason: &str) -> String {
|
||
self.0.render(
|
||
Key::InstancesModsToggleFailed,
|
||
&[("file", file), ("reason", reason)],
|
||
)
|
||
}
|
||
/// 启停失败大类的短句(`ModFailureKind` → 一句话;未知取值不猜,原样上屏)。
|
||
pub(super) fn mods_reason(self, kind: ModFailureKind) -> &'static str {
|
||
self.text(match kind {
|
||
ModFailureKind::Running => Key::InstancesModsReasonRunning,
|
||
ModFailureKind::InstanceUnavailable => Key::InstancesModsReasonInstance,
|
||
ModFailureKind::UnsafeName => Key::InstancesModsReasonName,
|
||
ModFailureKind::ModsDirUnusable => Key::InstancesModsReasonDirectory,
|
||
ModFailureKind::NotFound => Key::InstancesModsReasonMissing,
|
||
ModFailureKind::NotARegularFile => Key::InstancesModsReasonNotFile,
|
||
ModFailureKind::Collision => Key::InstancesModsReasonCollision,
|
||
ModFailureKind::Rename => Key::InstancesModsReasonRename,
|
||
})
|
||
}
|
||
|
||
// ------------------------------------------------------------- 存档区(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` 里一条一条写着),
|
||
/// 新加的 key 只要还在这个族里就自动进守门范围,不需要有人记得来登记。
|
||
#[cfg(test)]
|
||
pub(super) fn keys() -> impl Iterator<Item = Key> {
|
||
Key::ALL
|
||
.iter()
|
||
.copied()
|
||
.filter(|key| key.name().starts_with("Instances"))
|
||
}
|
||
}
|