Files
Unlucky-Launcher/crates/app-iced/src/pages/instances/text.rs
T
AskaEth 8c67e4eca6 fix(presenter,app-iced): 「游戏在跑」挡在落 core 之前(M247 r2)
塔裁决:三个写操作在实例启动/运行时必须拒,判据不能只靠界面灰按钮 ——
Windows 上是 os error 32 兜底,**Linux 上删一个正在跑的世界会成功**,那是在
用户玩到一半时把存档删掉。

- host/dispatch.rs:`AppCommand::Saves` 与 `AppCommand::Mods` 同款,先查
  `CoreHost::in_flight(&id)` 再把 `running` 传下去
- host/saves.rs::dispatch 增 `running`:为真时三个写操作**不 spawn、不碰盘**,
  直接回一条 `SavesFailureKind::GameRunning` 的结构化失败;读路径与「打开文件夹」不受影响
- dto:新增 `GameRunning` 类别(不折进 WriteFailed —— 那不是"写失败",是"我们主动没写")
- i18n:「游戏正在运行(先退出再操作)」中英成对 + 金表同步
- 用例:在飞时三条写命令盘上指纹不变/世界还在/备份落点没建,且回 GameRunning;
  反向用例钉住闸门没有把写操作一并关死;UI 侧断言三种动词的主句都不出现「写盘失败」且不画重试
- 拆分(400 行):host/saves.rs → host/saves/write.rs;两处测试各拆出 write 子模块
- SavesState 模块文档补「生命周期」表:draft / pending / confirm 谁写谁清、何时必须清,
  并把 confirm「取走即清」(连点两下不删两个)写成安全不变量
- 出图:ULL_SAVES_FIXTURE=running 一张(失败那句话的样子)
2026-11-11 13:29:56 +08:00

391 lines
17 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)
}
/// 「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,
SavesFailureKind::WriteUnsafeName => Key::InstancesSavesReasonUnsafeName,
SavesFailureKind::WriteSymlink => Key::InstancesSavesReasonSymlink,
SavesFailureKind::WriteMissing => Key::InstancesSavesReasonMissing,
SavesFailureKind::WriteNotADirectory => Key::InstancesSavesReasonNotDir,
SavesFailureKind::WriteConflict => Key::InstancesSavesReasonConflict,
SavesFailureKind::WriteBoundary => Key::InstancesSavesReasonBoundary,
SavesFailureKind::WriteTooLarge => Key::InstancesSavesReasonTooLarge,
SavesFailureKind::GameRunning => Key::InstancesSavesReasonRunning,
SavesFailureKind::WriteFailed => Key::InstancesSavesReasonWrite,
})
}
// ------------------------------------------------- 存档写操作(M247 · SAVESOPS)
pub(super) fn saves_rename(self) -> &'static str {
self.text(Key::InstancesSavesRename)
}
pub(super) fn saves_backup(self) -> &'static str {
self.text(Key::InstancesSavesBackup)
}
pub(super) fn saves_delete(self) -> &'static str {
self.text(Key::InstancesSavesDelete)
}
/// 行内输入框的占位符(不是按钮文案:它说「该填什么」)。
pub(super) fn saves_rename_placeholder(self) -> &'static str {
self.text(Key::InstancesSavesRenamePlaceholder)
}
pub(super) fn saves_rename_confirm(self) -> &'static str {
self.text(Key::InstancesSavesRenameConfirm)
}
pub(super) fn saves_cancel(self) -> &'static str {
self.text(Key::InstancesSavesCancel)
}
/// 「正在处理…」:写请求在飞时顶替那一行的操作区。
pub(super) fn saves_busy(self) -> &'static str {
self.text(Key::InstancesSavesBusy)
}
pub(super) fn saves_delete_title(self) -> &'static str {
self.text(Key::InstancesSavesDeleteTitle)
}
/// 删除确认框的正文:`{dir} 和它里面的全部内容都会被删除,且不能撤销。`
pub(super) fn saves_delete_body(self, dir: &str) -> String {
self.0
.render(Key::InstancesSavesDeleteBody, &[("dir", dir)])
}
pub(super) fn saves_delete_confirm(self) -> &'static str {
self.text(Key::InstancesSavesDeleteConfirm)
}
/// 改名成功的回执:`已把 A 改名为 B。`
pub(super) fn saves_renamed(self, from: &str, to: &str) -> String {
self.0
.render(Key::InstancesSavesRenamed, &[("from", from), ("to", to)])
}
/// 删除成功的回执:`已删除 A。`
pub(super) fn saves_deleted(self, dir: &str) -> String {
self.0.render(Key::InstancesSavesDeleted, &[("dir", dir)])
}
/// 备份成功的回执:`已把 A 备份到 D:/…/A-…zip(1.2 MiB)。`
pub(super) fn saves_backed_up(self, dir: &str, path: &str, size: &str) -> String {
self.0.render(
Key::InstancesSavesBackedUp,
&[("dir", dir), ("path", path), ("size", size)],
)
}
/// 「跳过 N 个符号链接」(备份时没跟随的那些)。
pub(super) fn saves_backup_skipped(self, count: usize) -> String {
self.0.render(
Key::InstancesSavesBackupSkipped,
&[("count", &count.to_string())],
)
}
/// 重命名失败的主句:`重命名 A 失败:同名世界已存在`。
pub(super) fn saves_rename_failed(self, file: &str, reason: &str) -> String {
self.0.render(
Key::InstancesSavesRenameFailed,
&[("file", file), ("reason", reason)],
)
}
/// 删除失败的主句。
pub(super) fn saves_delete_failed(self, file: &str, reason: &str) -> String {
self.0.render(
Key::InstancesSavesDeleteFailed,
&[("file", file), ("reason", reason)],
)
}
/// 备份失败的主句。
pub(super) fn saves_backup_failed(self, file: &str, reason: &str) -> String {
self.0.render(
Key::InstancesSavesBackupFailed,
&[("file", file), ("reason", reason)],
)
}
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"))
}
}