Files
Unlucky-Launcher/crates/app-iced/src/pages/instances/text.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

306 lines
13 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.
//! 实例页的**文案入口**:页面上一处可见英文字面量都不留,全部从目录取。
//!
//! ## 为什么要有这一层(而不是在控件树里直接 `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"))
}
}