feat(core,presenter,app-iced): 存档三个写操作(重命名 / 删除 / 备份)+ 行操作区(M247 SAVESOPS)
core::game::saves 新增 write 模块,三个入口的第一句都是 boundary::guard: - rename_world:saves/<old> → saves/<new>,撞名/同名/非法名字各自具名拒绝 - delete_world:递归删(不跟随符号链接、层级上限 64),先过边界校验 - backup_world:流式 zip(限额预检 → 临时文件 → 原子改名), <世界名>-YYYYMMDD-HHMMSS.zip,撞名顺延,绝不覆盖上一份 guard 三道判据(每次写前重做,不信任调用方):名字必须是单段裸名字; saves/ 必须是运行目录的真实直属子目录;世界目录必须是 saves/ 下的真目录 (解析后比父级,不比字符串前缀)。落点目录要求绝对路径(相对路径会落到进程 CWD, 实测过一次)。 presenter:SavesCommand 增 RenameWorld/DeleteWorld/BackupWorld,host 三个后台入口 写成功后重新列一遍目录并把「回执」挂在快照上(SavesOped:改名两端 / 被删的名字 / 备份落点+字节数+跳过的链接数)。SavesFailureView 增 file 与 op,写失败八类一一对应。 SavesState 增 draft / pending / confirm 三样交互态(草稿是行内输入缓冲, pending 与 ModsState::pending 同一条先例,confirm 是删除确认框在等谁)。 app-iced:世界行右侧重命名/备份/删除;行内输入框改名(现成模态队列没有「输入变了」 这一条消息,用输入模态等于重写一套);删除走现成 modal 的 Confirm,主按钮落点接在 app/update/dialog.rs。回执行画在卡片里(备份落点看不见就是静默失败)。 i18n:新增 25 条中英成对 key + 金表同步;删掉 M235 的「这一版只读」那条(写操作接上 之后它是假的)。出图夹具 ULL_SAVES_FIXTURE=rename|confirm|backed-up 三种形态。
This commit is contained in:
1 parent
3e852331f4
commit
c921b9c185
28 files changed
+3016
-216
No files matched your search
@@ -178,7 +178,7 @@ impl App {
|
||||
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
|
||||
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
|
||||
if saves_shot::enabled() {
|
||||
self.state.saves.changed(saves_shot::view(&id));
|
||||
saves_shot::seed(self, &id);
|
||||
return;
|
||||
}
|
||||
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
|
||||
|
||||
@@ -77,5 +77,76 @@ pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
|
||||
shot("2024-04-20_08.30.00.png", 964_608, None),
|
||||
],
|
||||
skipped: Vec::new(),
|
||||
// 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。
|
||||
// 要看「备份成功那句话」长什么样,用 `ULL_SAVES_FIXTURE=backed-up`(见 `seed`)。
|
||||
last_op: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 夹具形态(由 `ULL_SAVES_FIXTURE` 的取值决定;未知值 = 默认那一种)。
|
||||
///
|
||||
/// | 取值 | 出图抓什么 |
|
||||
/// |---|---|
|
||||
/// | `1`(或任何未知值) | 有内容的列表:世界行右侧的重命名 / 备份 / 删除 |
|
||||
/// | `rename` | 行内重命名进行中(输入框 + 改名/取消) |
|
||||
/// | `confirm` | 删除的二次确认模态框 |
|
||||
/// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) |
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// 默认:列表 + 行操作区。
|
||||
Plain,
|
||||
/// 行内重命名进行中。
|
||||
Rename,
|
||||
/// 删除确认模态。
|
||||
Confirm,
|
||||
/// 备份成功的回执。
|
||||
BackedUp,
|
||||
}
|
||||
|
||||
fn mode() -> Mode {
|
||||
match std::env::var("ULL_SAVES_FIXTURE")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
{
|
||||
"rename" => Mode::Rename,
|
||||
"confirm" => Mode::Confirm,
|
||||
"backed-up" => Mode::BackedUp,
|
||||
_ => Mode::Plain,
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入这一屏需要的状态(**只走产品路径的入口**:`changed` / `start_rename` / `ask_delete` /
|
||||
/// `dialogs.push`,一处新渲染分支都没有)。
|
||||
pub fn seed(app: &mut crate::app::state::App, instance: &str) {
|
||||
let mode = mode();
|
||||
let mut view = view(instance);
|
||||
if mode == Mode::BackedUp {
|
||||
view.last_op = Some(unlucky_presenter::dto::SavesOped::BackedUp {
|
||||
dir: "Better World".to_string(),
|
||||
file: "Better World-20240501-100000.zip".to_string(),
|
||||
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
|
||||
bytes: 84_213_760,
|
||||
skipped: 0,
|
||||
});
|
||||
}
|
||||
let first = view.worlds.first().map(|world| world.dir.clone());
|
||||
app.state.saves.changed(view);
|
||||
let Some(dir) = first else {
|
||||
return;
|
||||
};
|
||||
match mode {
|
||||
Mode::Rename => app.state.saves.start_rename(&dir),
|
||||
Mode::Confirm => {
|
||||
app.state.saves.ask_delete(&dir);
|
||||
let (title, body, primary, secondary) =
|
||||
crate::pages::saves_delete_prompt(app.lang().catalog(), &dir);
|
||||
app.state.dialogs.push(
|
||||
unlucky_presenter::dialogs::DialogSource::UserAction,
|
||||
title,
|
||||
body,
|
||||
unlucky_presenter::dialogs::DialogKind::Confirm { primary, secondary },
|
||||
);
|
||||
}
|
||||
Mode::Plain | Mode::BackedUp => {}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,24 @@ use crate::app::state::App;
|
||||
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
|
||||
match message {
|
||||
DialogMessage::Primary => {
|
||||
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
|
||||
// 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。
|
||||
// 取走即清,所以连点两下不会删两个(第二下拿不到目标 ⇒ 只关框)。
|
||||
if let Some(dir) = app.state.saves.take_confirm() {
|
||||
app.close_dialog();
|
||||
if let Some(instance) = app.selected_instance_id() {
|
||||
app.state
|
||||
.saves
|
||||
.mark_pending(unlucky_presenter::state::SavesOp::Delete {
|
||||
dir: dir.clone(),
|
||||
});
|
||||
app.dispatch(unlucky_presenter::dto::AppCommand::Saves(Box::new(
|
||||
unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir },
|
||||
)));
|
||||
}
|
||||
app.refresh_view();
|
||||
return Task::none();
|
||||
}
|
||||
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
app.close_dialog();
|
||||
@@ -38,6 +56,8 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::Secondary | DialogMessage::Dismiss => {
|
||||
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
|
||||
app.state.saves.clear_confirm();
|
||||
app.close_dialog();
|
||||
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||||
// (r1 复审 P3-1)。
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
|
||||
use unlucky_presenter::state::SavesOp;
|
||||
|
||||
use crate::pages::InstanceMessage;
|
||||
|
||||
@@ -56,6 +58,73 @@ impl App {
|
||||
})));
|
||||
return Task::none();
|
||||
}
|
||||
// M247:行内重命名的三个动作。**输入过程不 dispatch**(只改 presenter 的草稿),
|
||||
// 提交才发命令——与启动参数表单「编辑只改状态,保存才派命令」同一条纪律。
|
||||
InstanceMessage::StartRenameWorld { dir } => {
|
||||
self.state.saves.start_rename(&dir);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
return Task::none();
|
||||
}
|
||||
InstanceMessage::RenameWorldChanged(text) => {
|
||||
self.state.saves.edit_rename(text);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
return Task::none();
|
||||
}
|
||||
InstanceMessage::CommitRenameWorld => {
|
||||
let Some(draft) = self.state.saves.take_rename() else {
|
||||
return Task::none();
|
||||
};
|
||||
// 空名字 / 没改 → 直接收起,不打后台:这两个不是"操作失败",是"用户改主意了"。
|
||||
if draft.text.is_empty() || draft.text == draft.dir {
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
return Task::none();
|
||||
}
|
||||
self.state.saves.mark_pending(SavesOp::Rename {
|
||||
dir: draft.dir.clone(),
|
||||
});
|
||||
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::RenameWorld {
|
||||
id: instance,
|
||||
dir: draft.dir,
|
||||
to: draft.text,
|
||||
})));
|
||||
return Task::none();
|
||||
}
|
||||
InstanceMessage::CancelRenameWorld => {
|
||||
self.state.saves.cancel_rename();
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
return Task::none();
|
||||
}
|
||||
// M247:删除**先弹确认框**。确认目标是"哪个世界"必须记在状态里——
|
||||
// 主按钮落点在 `app/update/dialog.rs`,那里只有 `App`,没有这个页面。
|
||||
InstanceMessage::RequestDeleteWorld { dir } => {
|
||||
self.state.saves.ask_delete(&dir);
|
||||
let (title, body, primary, secondary) =
|
||||
crate::pages::saves_delete_prompt(self.lang().catalog(), &dir);
|
||||
self.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
title,
|
||||
body,
|
||||
DialogKind::Confirm { primary, secondary },
|
||||
);
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
}
|
||||
// M247:备份——落点由 host 定(`<data>/exports/saves/`),回执里带完整路径。
|
||||
InstanceMessage::BackupWorld { dir } => {
|
||||
self.state
|
||||
.saves
|
||||
.mark_pending(SavesOp::Backup { dir: dir.clone() });
|
||||
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::BackupWorld {
|
||||
id: instance,
|
||||
dir,
|
||||
dest: None,
|
||||
})));
|
||||
return Task::none();
|
||||
}
|
||||
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
||||
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
|
||||
InstanceMessage::Jvm(text) => {
|
||||
@@ -96,7 +165,9 @@ impl App {
|
||||
}
|
||||
|
||||
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
|
||||
fn selected_instance_id(&self) -> Option<String> {
|
||||
///
|
||||
/// `pub(in crate::app)`(M247):删除确认框的主按钮在 `app/update/dialog.rs`,也要问同一个问题。
|
||||
pub(in crate::app) fn selected_instance_id(&self) -> Option<String> {
|
||||
match self.state.route() {
|
||||
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
|
||||
@@ -75,6 +75,23 @@ pub fn saves_failure_line(
|
||||
saves::failure_line(failure, Texts::of(catalog))
|
||||
}
|
||||
|
||||
/// 删除世界的确认框文案:`(标题, 正文, 主按钮, 次按钮)`。
|
||||
///
|
||||
/// 模态框在 app 层建(`state.dialogs.push`),但**取词入口仍是本页的 [`Texts`]**——与
|
||||
/// [`saves_failure_line`] 同一条口径:页面文案只有一个入口,测试扫的也是那一份源码。
|
||||
pub fn saves_delete_prompt(
|
||||
catalog: unlucky_i18n::Catalog,
|
||||
dir: &str,
|
||||
) -> (String, String, String, String) {
|
||||
let texts = Texts::of(catalog);
|
||||
(
|
||||
texts.saves_delete_title().to_string(),
|
||||
texts.saves_delete_body(dir),
|
||||
texts.saves_delete_confirm().to_string(),
|
||||
texts.saves_cancel().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。
|
||||
pub fn mods_failure_line(
|
||||
failure: &unlucky_presenter::dto::ModFailureView,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP):存档 / 截图的**只读**列表 + 打开文件夹。
|
||||
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP 读路径 + M247 · SAVESOPS 写操作):
|
||||
//! 存档 / 截图列表 + 打开文件夹 + **每行的重命名 / 备份 / 删除**。
|
||||
//!
|
||||
//! # 位置与形状
|
||||
//!
|
||||
@@ -14,17 +15,42 @@
|
||||
//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条
|
||||
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
|
||||
//!
|
||||
//! # 只读(U3 的边界,页面固定画出来)
|
||||
//! # 行操作区(M247)
|
||||
//!
|
||||
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」:没有删除、没有重命名、没有导入、**没有缩略图**
|
||||
//! (缩略图要引图片解码依赖,`AGENTS.md` §2 不允许)。所以卡片里第一行永远画
|
||||
//! [`Texts::saves_read_only`]——用户点开就看见"这一版只读",不会以为漏了删除按钮。
|
||||
//! 每个**世界**行右侧三个文字按钮:**重命名 / 备份 / 删除**(截图行没有操作区:截图不在这三个
|
||||
//! 写操作的范围里)。三档状态:
|
||||
//!
|
||||
//! | 状态 | 画什么 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 空闲 | 三个按钮 | — |
|
||||
//! | 这一行的请求在飞 | 一句「正在处理…」顶替按钮 | 连点两次会发两条 rename/delete,第二条必然「世界已不在」 |
|
||||
//! | 正在行内改名 | 输入框 + 「改名」/「取消」 | 见下 |
|
||||
//!
|
||||
//! **不做乐观回显**:行上的名字只来自快照,点完等 host 回一份新快照才变(与 MODS 区同一纪律)。
|
||||
//!
|
||||
//! # 重命名为什么是行内输入,不是模态输入框
|
||||
//!
|
||||
//! 现成的模态队列有 `DialogKind::Input`,但 app 侧的 `dialog::Message` 只有主/次按钮与关闭,
|
||||
//! **没有「输入变了」这一条**——用输入模态等于重写一套可编辑模态。行内输入复用
|
||||
//! `components::inputs` 的单行框(与启动参数区同一个控件),一处新控件都不加。
|
||||
//! 输入缓冲住在 presenter 的 [`SavesState::draft`](页面模型每帧从快照重建,缓冲留在页面里会被
|
||||
//! 每一次事件冲掉)。
|
||||
//!
|
||||
//! # 删除为什么必须二次确认
|
||||
//!
|
||||
//! 这是本页唯一**不可逆**的动作。点「删除」只发一条
|
||||
//! [`PageMessage::RequestDeleteWorld`],真正的删除要等模态确认框的主按钮
|
||||
//! (`app/update/dialog.rs` 里那一支)。确认框复用仓内现成的模态队列(`state.dialogs`),
|
||||
//! 页面不新写弹窗。
|
||||
//!
|
||||
//! # 失败与重试
|
||||
//!
|
||||
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败不画重试**:
|
||||
//! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance
|
||||
//! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。
|
||||
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败与写操作
|
||||
//! 失败不画重试**:那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的
|
||||
//! affordance(三种失败共用 `detail`,靠大类与 `op` 分别,见 [`failure_line`])。
|
||||
//!
|
||||
//! 写操作**成功**也有一句话([`Saves::notice`]):重命名 / 删除的结果列表上看得见,但备份的落点
|
||||
//! 看不见——「点了备份然后什么也没发生」是静默失败,所以回执里带上完整路径与字节数。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
@@ -33,7 +59,7 @@ 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::dto::{SavesFailureKind, SavesFailureOp, SavesFailureView};
|
||||
use unlucky_presenter::state::SavesState;
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
@@ -46,15 +72,28 @@ use super::forced_state;
|
||||
use super::state::Message as PageMessage;
|
||||
use super::Texts;
|
||||
|
||||
/// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。
|
||||
mod rows;
|
||||
/// 写操作成功那句话的产地(实现在 [`rows`] 里,页面与测试都从这一条路取)。
|
||||
pub(super) use rows::op_line;
|
||||
|
||||
/// 列表里的一行(一个世界 / 一张截图)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// 世界目录名或截图文件名(磁盘原样)。
|
||||
/// 世界目录名或截图文件名(磁盘原样;写操作把它原样带回去当「源」)。
|
||||
pub name: String,
|
||||
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 正在行内重命名的那一行(来源是 presenter 的 `SavesState::draft`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Editing {
|
||||
/// 哪个世界。
|
||||
pub dir: String,
|
||||
/// 输入框里的内容(原样)。
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// SAVES 区的展示模型(`Model::saves`)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Saves {
|
||||
@@ -62,9 +101,9 @@ pub struct Saves {
|
||||
pub bound: Option<String>,
|
||||
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
|
||||
pub loading: bool,
|
||||
/// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。
|
||||
/// 读盘/打开/写操作失败的人读原因(app 层按 i18n 拼好)。
|
||||
pub error: Option<String>,
|
||||
/// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。
|
||||
/// 这一条失败能不能靠「再列一次」解决(只有读盘失败算)。
|
||||
pub retryable: bool,
|
||||
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
|
||||
pub has_view: bool,
|
||||
@@ -84,6 +123,12 @@ pub struct Saves {
|
||||
pub skipped: usize,
|
||||
/// 跳过提示(`跳过 3 项`)。
|
||||
pub skipped_note: String,
|
||||
/// 正在行内重命名的那一行(`None` = 没有在改名)。
|
||||
pub editing: Option<Editing>,
|
||||
/// 有写操作在飞的那个世界(`None` = 空闲)。
|
||||
pub busy: Option<String>,
|
||||
/// 上一次写操作成功的那句话(`None` = 这一拍没有回执)。
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
impl Saves {
|
||||
@@ -98,21 +143,33 @@ impl Saves {
|
||||
pub fn nothing_yet(&self) -> bool {
|
||||
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
|
||||
}
|
||||
|
||||
/// 这个世界的写操作在飞吗(那一行画「正在处理…」并把按钮拿掉)。
|
||||
#[must_use]
|
||||
pub fn busy_with(&self, dir: &str) -> bool {
|
||||
self.busy.as_deref() == Some(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
|
||||
///
|
||||
/// 两条口径:
|
||||
/// 三条口径:
|
||||
///
|
||||
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
|
||||
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
|
||||
/// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。
|
||||
/// 2. **不做乐观更新**:行上的名字只来自快照,写操作的"在飞"只用来置灰那一行;
|
||||
/// 3. **回执只画一次**:`view.last_op` 是 host 写在快照里的,下一次普通 List 就没了。
|
||||
#[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(),
|
||||
editing: state.draft.as_ref().map(|draft| Editing {
|
||||
dir: draft.dir.clone(),
|
||||
text: draft.text.clone(),
|
||||
}),
|
||||
busy: state.pending.as_ref().map(|op| op.dir().to_string()),
|
||||
..Saves::default()
|
||||
};
|
||||
if let Some(view) = &state.view {
|
||||
@@ -125,7 +182,7 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
||||
.iter()
|
||||
.map(|world| Row {
|
||||
name: world.dir.clone(),
|
||||
detail: world_detail(world.icon, world.modified_ms, texts),
|
||||
detail: rows::world_detail(world.icon, world.modified_ms, texts),
|
||||
})
|
||||
.collect();
|
||||
out.screenshots = view
|
||||
@@ -133,30 +190,33 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
||||
.iter()
|
||||
.map(|shot| Row {
|
||||
name: shot.file.clone(),
|
||||
detail: shot_detail(shot.bytes, shot.modified_ms, texts),
|
||||
detail: rows::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);
|
||||
out.notice = view.last_op.as_ref().map(|op| op_line(op, texts));
|
||||
}
|
||||
if let Some(failure) = &state.failed {
|
||||
out.loading = false;
|
||||
out.error = Some(failure_line(failure, texts));
|
||||
out.retryable = failure.kind != SavesFailureKind::OpenFailed;
|
||||
// 只有「读盘 / 列目录」失败才值得重试;写操作的重试就是再点一次那个按钮。
|
||||
out.retryable = failure.op.is_none() && failure.kind != SavesFailureKind::OpenFailed;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 失败的一句话(**唯一产地**:页面内红字用它;本版没有 Toast——只读列表的失败就地说明更近)。
|
||||
/// 失败的一句话(**唯一产地**:页面内红字用它,本版没有 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)
|
||||
}
|
||||
let sentence = match (failure.op, failure.kind) {
|
||||
(Some(SavesFailureOp::Rename), _) => texts.saves_rename_failed(&failure.file, reason),
|
||||
(Some(SavesFailureOp::Delete), _) => texts.saves_delete_failed(&failure.file, reason),
|
||||
(Some(SavesFailureOp::Backup), _) => texts.saves_backup_failed(&failure.file, reason),
|
||||
(None, SavesFailureKind::OpenFailed) => texts.saves_open_failed(reason),
|
||||
(None, _) => texts.saves_failed(reason),
|
||||
};
|
||||
if failure.detail.is_empty() {
|
||||
return sentence;
|
||||
@@ -164,44 +224,6 @@ pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
|
||||
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,
|
||||
@@ -212,8 +234,6 @@ pub(super) fn section<'a>(
|
||||
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()));
|
||||
@@ -255,6 +275,10 @@ pub(super) fn section<'a>(
|
||||
body = body.push(note(ui, texts.saves_empty()));
|
||||
return frame::card(ui, body);
|
||||
}
|
||||
// 写操作成功的回执(重命名 / 删除 / 备份)——放在列表**上面**:用户刚点的就是这里。
|
||||
if let Some(notice) = saves.notice.as_deref() {
|
||||
body = body.push(note(ui, notice));
|
||||
}
|
||||
|
||||
// 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。
|
||||
body = body.push(sub_head(ui, texts.saves_worlds()));
|
||||
@@ -262,7 +286,7 @@ pub(super) fn section<'a>(
|
||||
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(rows::world(ui, item, saves, forced, texts));
|
||||
}
|
||||
}
|
||||
body = body.push(sub_head(ui, texts.saves_screenshots()));
|
||||
@@ -270,7 +294,7 @@ pub(super) fn section<'a>(
|
||||
body = body.push(note(ui, texts.saves_no_screenshots()));
|
||||
} else {
|
||||
for item in &saves.screenshots {
|
||||
body = body.push(entry_row(ui, item));
|
||||
body = body.push(rows::world_plain(ui, item));
|
||||
}
|
||||
}
|
||||
if saves.skipped > 0 {
|
||||
@@ -350,29 +374,6 @@ fn sub_head<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
|
||||
)
|
||||
}
|
||||
|
||||
/// 一行:名字 + 副文案(只读,所以没有开关、没有按钮)。
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! SAVES 区的一行与它的操作区(M247)。
|
||||
//!
|
||||
//! 从 `saves.rs` 拆出:那个文件加完写操作的行内输入与三个按钮之后会越过 400 行硬线
|
||||
//! (拆法与仓库其它 `-p1/-p2` 一致——**纯搬移 + 一个函数一族**,判据留在 `saves.rs`)。
|
||||
//!
|
||||
//! | 函数 | 画什么 |
|
||||
//! |---|---|
|
||||
//! | [`world`] | 一个世界:名字 + 副文案 +(空闲)重命名/备份/删除、(在飞)「正在处理…」、(改名中)输入框 + 改名/取消 |
|
||||
//! | [`world_plain`] | 只读的一行(截图用它:截图不在三个写操作的范围里) |
|
||||
//! | [`world_detail`] / [`shot_detail`] | 两行副文案 |
|
||||
//!
|
||||
//! 三枚按钮与「打开存档文件夹」同一条口径:`buttons::text` + `frame::BASE`(M237:卡片里的控件
|
||||
//! 把底传成卡底,hover/禁用状态才叠在卡实色上),出图取证时整行一起跟 `--force-state`。
|
||||
//!
|
||||
//! 输入框用 `components::inputs::text_field`(与启动参数区同一个控件)。**回车即提交**这件事
|
||||
//! 由 `text_input` 的 `on_submit` 给不了(`Field` 没有开这个口子),所以提交靠右边的「改名」按钮
|
||||
//! ——少一个键,但多一条谁都能看见的路。
|
||||
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_presenter::dto::{human_bytes, SavesOped};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::inputs::{text_field, Field};
|
||||
use crate::app::components::interaction::Interaction;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::forced_state;
|
||||
use super::super::frame;
|
||||
use super::super::state::Message as PageMessage;
|
||||
use super::super::Message;
|
||||
use super::super::Texts;
|
||||
use super::{Row, Saves};
|
||||
|
||||
/// 一个世界行:名字 + 副文案 + 操作区(三档状态见模块文档与 `saves.rs`)。
|
||||
pub(super) fn world<'a>(
|
||||
ui: &'a Ui,
|
||||
item: &'a Row,
|
||||
saves: &'a Saves,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
if let Some(editing) = saves.editing.as_ref().filter(|edit| edit.dir == item.name) {
|
||||
return rename_row(ui, editing.text.as_str(), forced, texts);
|
||||
}
|
||||
let label = labels(ui, item, Role::OnSurface);
|
||||
let dir = item.name.clone();
|
||||
let actions: Element<'a, Message> = if saves.busy_with(&dir) {
|
||||
text_note(ui, texts.saves_busy(), Role::OnSurfaceVariant)
|
||||
} else {
|
||||
row![
|
||||
action(
|
||||
ui,
|
||||
texts.saves_rename(),
|
||||
PageMessage::StartRenameWorld { dir: dir.clone() },
|
||||
forced
|
||||
),
|
||||
action(
|
||||
ui,
|
||||
texts.saves_backup(),
|
||||
PageMessage::BackupWorld { dir: dir.clone() },
|
||||
forced
|
||||
),
|
||||
action(
|
||||
ui,
|
||||
texts.saves_delete(),
|
||||
PageMessage::RequestDeleteWorld { dir },
|
||||
forced
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
};
|
||||
row![label, actions]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 只读的一行(截图)。与 [`world`] 的区别只有一条:没有操作区。
|
||||
pub(super) fn world_plain<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> {
|
||||
labels(ui, item, Role::OnSurface).into()
|
||||
}
|
||||
|
||||
/// 行内重命名:输入框 + 「改名」/「取消」(占满整行,副文案让位)。
|
||||
fn rename_row<'a>(
|
||||
ui: &'a Ui,
|
||||
text: &'a str,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
let field = text_field(
|
||||
ui,
|
||||
Field::new(text)
|
||||
// M237:输入框住在卡里 ⇒ 状态层与描边叠卡底。
|
||||
.base(frame::BASE)
|
||||
.placeholder(texts.saves_rename_placeholder())
|
||||
.width(Length::Fill),
|
||||
|value| Message::Instances(PageMessage::RenameWorldChanged(value)),
|
||||
);
|
||||
row![
|
||||
field,
|
||||
action(
|
||||
ui,
|
||||
texts.saves_rename_confirm(),
|
||||
PageMessage::CommitRenameWorld,
|
||||
forced
|
||||
),
|
||||
action(
|
||||
ui,
|
||||
texts.saves_cancel(),
|
||||
PageMessage::CancelRenameWorld,
|
||||
forced
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 名字 + 副文案(两行)。
|
||||
fn labels<'a>(ui: &'a Ui, item: &'a Row, color: Role) -> iced::widget::Column<'a, Message> {
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
item.name.as_str(),
|
||||
ui.color(color)
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
item.detail.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
}
|
||||
|
||||
/// 一枚行内文字按钮(统一套 `frame::BASE` 与 `--force-state`)。
|
||||
fn action<'a>(
|
||||
ui: &'a Ui,
|
||||
label: &'a str,
|
||||
message: PageMessage,
|
||||
forced: Option<Interaction>,
|
||||
) -> Element<'a, Message> {
|
||||
buttons::text(
|
||||
ui,
|
||||
forced_state(Button::new(label, Message::Instances(message)), forced).base(frame::BASE),
|
||||
)
|
||||
}
|
||||
|
||||
fn text_note<'a>(ui: &'a Ui, text: &'a str, color: Role) -> Element<'a, Message> {
|
||||
frame::text_role(ui, TypeRole::BodySmall, false, text, ui.color(color))
|
||||
}
|
||||
|
||||
/// 写操作成功后的一句话(**唯一产地**:卡片里那条成功提示)。
|
||||
///
|
||||
/// 备份那句必须带**完整路径**:落点在文件系统某处,用户得知道去哪找。
|
||||
#[must_use]
|
||||
pub(crate) fn op_line(op: &SavesOped, texts: Texts) -> String {
|
||||
match op {
|
||||
SavesOped::Renamed { from, to } => texts.saves_renamed(from, to),
|
||||
SavesOped::Deleted { dir } => texts.saves_deleted(dir),
|
||||
SavesOped::BackedUp {
|
||||
dir,
|
||||
path,
|
||||
bytes,
|
||||
skipped,
|
||||
..
|
||||
} => {
|
||||
let mut line = texts.saves_backed_up(dir, path, &human_bytes(*bytes));
|
||||
if *skipped > 0 {
|
||||
// 备份跳过链接这件事必须说出来:静默少打包几个文件比失败更坏。
|
||||
line.push_str(" · ");
|
||||
line.push_str(&texts.saves_backup_skipped(*skipped));
|
||||
}
|
||||
line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 世界那一行的副文案:`2024-05-01 10:00 UTC`,有图标时再挂一个「有图标」。
|
||||
pub(super) 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`。
|
||||
pub(super) 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"))
|
||||
}
|
||||
@@ -170,6 +170,31 @@ pub enum Message {
|
||||
ReloadSaves,
|
||||
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
|
||||
OpenSavesFolder,
|
||||
/// 开始**行内重命名**一个世界(M247):那一行就地换成输入框 + 确定/取消。
|
||||
///
|
||||
/// 为什么是行内而不是模态输入框:现成的模态队列有 `DialogKind::Input`,但 app 侧的
|
||||
/// `dialog::Message` 只有「主按钮 / 次按钮 / 关闭」,**没有「输入变了」**这一条——
|
||||
/// 用输入模态等于重写一套可编辑模态。行内复用 `components::inputs` 的单行框,一处新控件都不用加。
|
||||
StartRenameWorld {
|
||||
/// 磁盘目录名
|
||||
dir: String,
|
||||
},
|
||||
/// 行内输入框的内容变了(原样存进 presenter 的草稿,不在前端过滤)。
|
||||
RenameWorldChanged(String),
|
||||
/// 提交行内重命名(空名字 / 没改 → 直接收起,不打扰后台)。
|
||||
CommitRenameWorld,
|
||||
/// 取消行内重命名。
|
||||
CancelRenameWorld,
|
||||
/// 请求删除一个世界(M247):**先弹确认框**,不直接删。
|
||||
RequestDeleteWorld {
|
||||
/// 磁盘目录名
|
||||
dir: String,
|
||||
},
|
||||
/// 备份一个世界(M247):导出 zip 到启动器自己的 `exports/saves/`。
|
||||
BackupWorld {
|
||||
/// 磁盘目录名
|
||||
dir: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -43,10 +43,13 @@ const ALLOWED: &[&str] = &[
|
||||
// 翻成「存档目录」用户就对不上那两层文件夹了。
|
||||
"saves",
|
||||
"screenshots",
|
||||
// M247:`zip` 是**归档格式名**(备份导出成什么,玩家拿去给别的东西解压时看的也是这个词)。
|
||||
// 中文界面上写「压缩包」会让「备份出来的是 .zip 吗」这个问题没有答案。
|
||||
"zip",
|
||||
];
|
||||
|
||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
const PAGE_SOURCES: [(&str, &str); 9] = [
|
||||
("instances.rs", include_str!("../../instances.rs")),
|
||||
("instances/detail.rs", include_str!("../detail.rs")),
|
||||
("instances/demo.rs", include_str!("../demo.rs")),
|
||||
@@ -63,6 +66,8 @@ const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
("instances/mods.rs", include_str!("../mods.rs")),
|
||||
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
|
||||
("instances/saves.rs", include_str!("../saves.rs")),
|
||||
// M247:SAVES 区的行与操作区(重命名 / 备份 / 删除 + 行内输入框)
|
||||
("instances/saves/rows.rs", include_str!("../saves/rows.rs")),
|
||||
];
|
||||
|
||||
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
|
||||
@@ -80,7 +85,7 @@ const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 30] = [
|
||||
const ALLOWED_LITERALS: [&str; 35] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
@@ -125,6 +130,13 @@ const ALLOWED_LITERALS: [&str; 30] = [
|
||||
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
|
||||
"worlds",
|
||||
"shots",
|
||||
// M247:删除确认与写操作回执的**占位符名**(`{dir}` 世界名 / `{from}` `{to}` 改名两端 /
|
||||
// `{path}` 备份落点 / `{size}` 人的可读字节数),都不是文案。
|
||||
"dir",
|
||||
"from",
|
||||
"to",
|
||||
"path",
|
||||
"size",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
|
||||
ScreenshotView, WorldView,
|
||||
SavesArea, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView, SavesOped,
|
||||
SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
||||
};
|
||||
use unlucky_presenter::state::SavesState;
|
||||
use unlucky_presenter::state::{SavesOp, SavesState};
|
||||
|
||||
use super::super::saves::{failure_line, from_state};
|
||||
use super::super::saves::{failure_line, from_state, op_line};
|
||||
use super::super::Texts;
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
@@ -42,6 +42,7 @@ fn view(instance: &str) -> SavesListView {
|
||||
file: "notes.txt".to_string(),
|
||||
reason: SavesSkipKind::NotAnImage,
|
||||
}],
|
||||
last_op: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +152,8 @@ fn only_list_failures_offer_a_retry() {
|
||||
state.changed(view("a"));
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind,
|
||||
detail: "Access is denied.".to_string(),
|
||||
});
|
||||
@@ -172,6 +175,8 @@ fn a_failure_without_a_snapshot_is_not_an_empty_list() {
|
||||
let mut state = bound_state("a");
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
});
|
||||
@@ -187,6 +192,8 @@ fn a_failure_without_a_snapshot_is_not_an_empty_list() {
|
||||
fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: String::new(),
|
||||
op: None,
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
};
|
||||
@@ -207,6 +214,137 @@ fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
||||
assert!(!text.contains('('), "空原文留下了一对括号:{text}");
|
||||
}
|
||||
|
||||
/// 写操作失败:**不画重试**(重试按钮会去重新列目录,那是错的动作),并且主句带**哪个世界**。
|
||||
///
|
||||
/// 抓得住:三种写失败被折叠成同一句「读取 saves/ 失败」——用户看不出是哪一行出了什么事。
|
||||
#[test]
|
||||
fn write_failures_are_named_per_operation_and_never_retryable() {
|
||||
let cases = [
|
||||
(
|
||||
SavesFailureOp::Rename,
|
||||
SavesFailureKind::WriteConflict,
|
||||
"重命名 Better World 失败:同名世界已存在",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Delete,
|
||||
SavesFailureKind::WriteMissing,
|
||||
"删除 Better World 失败:那个世界已不在",
|
||||
),
|
||||
(
|
||||
SavesFailureOp::Backup,
|
||||
SavesFailureKind::WriteTooLarge,
|
||||
"备份 Better World 失败:这个世界太大,一个 zip 装不下",
|
||||
),
|
||||
];
|
||||
for (op, kind, expected) in cases {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
file: "Better World".to_string(),
|
||||
op: Some(op),
|
||||
kind,
|
||||
detail: String::new(),
|
||||
};
|
||||
let line = failure_line(&failure, Texts::of(Lang::ZhCn.catalog()));
|
||||
assert_eq!(line, expected);
|
||||
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.failed(failure);
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
assert!(!saves.retryable, "{op:?} 失败了还画着重试按钮");
|
||||
assert!(saves.error.is_some());
|
||||
assert_eq!(saves.worlds.len(), 1, "{op:?} 失败把列表清了");
|
||||
}
|
||||
}
|
||||
|
||||
/// 三样交互态(草稿 / 在飞 / 回执)都要变成页面字段:改名中的那一行、置灰的那一行、成功那句话。
|
||||
#[test]
|
||||
fn draft_pending_and_receipt_become_page_fields() {
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.start_rename("Better World");
|
||||
state.edit_rename("Renamed".to_string());
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
let editing = saves.editing.as_ref().expect("草稿没进页面模型");
|
||||
assert_eq!(editing.dir, "Better World");
|
||||
assert_eq!(editing.text, "Renamed");
|
||||
assert!(!saves.busy_with("Better World"), "还在打字就算在飞了");
|
||||
|
||||
state.mark_pending(SavesOp::Backup {
|
||||
dir: "Better World".to_string(),
|
||||
});
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.editing.is_none(), "发请求时草稿该收起来");
|
||||
assert!(saves.busy_with("Better World"));
|
||||
assert!(!saves.busy_with("Other"));
|
||||
|
||||
// 回执:host 写在快照里,下一次普通 List 就没了。
|
||||
let mut done = view("a");
|
||||
done.last_op = Some(SavesOped::BackedUp {
|
||||
dir: "Better World".to_string(),
|
||||
file: "Better World-20240501-100000.zip".to_string(),
|
||||
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
|
||||
bytes: 2_048,
|
||||
skipped: 2,
|
||||
});
|
||||
state.changed(done);
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
let notice = saves.notice.as_deref().expect("回执没进页面模型");
|
||||
assert!(notice.contains("2.0 KB"), "回执要带大小:{notice}");
|
||||
assert!(
|
||||
notice.contains("Better World-20240501-100000.zip"),
|
||||
"{notice}"
|
||||
);
|
||||
assert!(
|
||||
notice.contains("跳过 2 个符号链接"),
|
||||
"跳过的链接要报出来:{notice}"
|
||||
);
|
||||
assert!(saves.busy.is_none(), "回程之后还在飞");
|
||||
}
|
||||
|
||||
/// 三种回执的措辞(重命名 / 删除 / 备份)。
|
||||
#[test]
|
||||
fn every_receipt_says_what_happened() {
|
||||
let texts = Texts::of(Lang::ZhCn.catalog());
|
||||
assert_eq!(
|
||||
op_line(
|
||||
&SavesOped::Renamed {
|
||||
from: "A".to_string(),
|
||||
to: "B".to_string()
|
||||
},
|
||||
texts
|
||||
),
|
||||
"已把 A 改名为 B。"
|
||||
);
|
||||
assert_eq!(
|
||||
op_line(
|
||||
&SavesOped::Deleted {
|
||||
dir: "A".to_string()
|
||||
},
|
||||
texts
|
||||
),
|
||||
"已删除 A。"
|
||||
);
|
||||
let backed_up = op_line(
|
||||
&SavesOped::BackedUp {
|
||||
dir: "A".to_string(),
|
||||
file: "A.zip".to_string(),
|
||||
path: "D:/x/A.zip".to_string(),
|
||||
bytes: 0,
|
||||
skipped: 0,
|
||||
},
|
||||
texts,
|
||||
);
|
||||
assert!(
|
||||
backed_up.starts_with("已把 A 备份到 D:/x/A.zip"),
|
||||
"{backed_up}"
|
||||
);
|
||||
assert!(
|
||||
!backed_up.contains("符号链接"),
|
||||
"没跳过就别提链接:{backed_up}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。
|
||||
#[test]
|
||||
fn millisecond_stamps_are_rendered_in_utc() {
|
||||
|
||||
@@ -216,10 +216,6 @@ impl Texts {
|
||||
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(
|
||||
@@ -279,15 +275,103 @@ impl Texts {
|
||||
self.0
|
||||
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
|
||||
}
|
||||
/// 失败大类的短句(`SavesFailureKind` → 一句话)。
|
||||
/// 失败大类的短句(`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::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)
|
||||
}
|
||||
|
||||
@@ -129,8 +129,8 @@ 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, saves_failure_line, saves_from_state, InstanceMessage,
|
||||
LaunchForm, Mods, Saves,
|
||||
mods_failure_line, mods_from_state, saves_delete_prompt, 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};
|
||||
|
||||
Reference in new issue
Block a user