Files
Unlucky-Launcher/crates/app-iced/src/pages/instances.rs
T
AskaEth c921b9c185 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 三种形态。
2026-11-11 13:29:56 +08:00

342 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.
//! 实例页:**list-detail** 的唯一用例,也是 M90 唯一打通到「真组件 + 真数据 + 真命令」的页。
//!
//! ## 版式(由 [`super::layout`] 决议,页面不自己算宽度)
//!
//! | 版面 | 结构 | 退化行为 |
//! |---|---|---|
//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,散文 ≤424 | — |
//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(块贴左吃满页宽,散文 ≤424) | 顶部给「Back to instances」,列表退成上一屏 |
//!
//! 为什么窄窗不是「上下堆」:官方 compact 下 list-detail 的口径是一次只显示一个 pane(列表 → 详情,
//! 整页切换)。上下堆会把详情挤到折叠线以下,等于详情不存在。
//!
//! ## M90:占位控件 → 真组件(逐项对照)
//!
//! | 位置 | M84(占位) | M90(真组件) |
//! |---|---|---|
//! | 列表行 | 手搓 `container` + `text_role`(行高 56) | `navigation::list_item`(一/两/三行 44/60/76,五态状态层) |
//! | 页头动作 | `frame::text_action`(纯 `text` + 手搓 hover) | `buttons::text`(`Button` 五式 + 状态层) |
//! | 「Play」 | `frame::text_action` | `buttons::filled`(**全屏唯一实心 accent**,rubric K2) |
//! | 分隔线 | `frame::divider` | `divider::full` |
//! | 数据 | `DEMO` 常量 | [`super::model::Instances`](真页面由 presenter 快照转,演示由 `DEMO` 转) |
//!
//! 判据没有因此放松:列宽仍由 [`super::layout`] 给(见 `pages/tests.rs` 的 424 断言)。
//!
//! ## 行高为什么从 56 变成 60(M90 登记)
//!
//! M84 的行高是「`row_height` 44 + `Space::X3` 12 = 56」的手工值;真 `ListItem` 的两行档是
//! **60**(令牌 `row_height` 44 + 每多一行 16,A1 是 72)。差异来自组件自己的几何真源,
//! 页面不许覆盖——这正是「接真组件」的应有结果。列表仍高于可视区,独立滚动条照样可见。
//!
//! ## 行距归零 + 底部内缩(M102,视觉复审 ②)
//!
//! 视觉复审在 1180×760 的截图里抓到「末行 Redstone Lab 被卡片底边切掉半行(副标题消失)」。
//! 根因是**两道页级附加量**:`Space::X1` 的行距让行节奏变成 64,与可视高度除不尽;
//! 列表滚动区贴到卡片的 1px 圆角描边上,切点正好落在描边线上。修法两件:
//!
//! | 改什么 | 值 | 结果 |
//! |---|---|---|
//! | 页级行距 | 4 → **0**(真源 [`ROW_SPACING`] = `None`) | 行节奏回到组件的 60 |
//! | 滚动区底部内缩 | 0 → **`Space::X2` 8**(真源 [`LIST_BOTTOM_INSET`]) | 切点落在卡片的内边距带里,不压描边 |
//!
//! 于是可视区 = 580 − 列头 32 − 内缩 8 = **540 = 9 × 60**,正好 9 整行:末行完整可见,
//! 第 10 行整体落在裁切线之外。两个数都**只有一个真源**,页面与护栏都从它取
//! (`instances/tests.rs` 引 [`LIST_BOTTOM_INSET`].`dp()` 与 [`ROW_SPACING`] 算 pitch,
//! 另有一条源码结构断言盯着 `list_column()` 不许再叠字面量间距)。
//! 列表内容 12×60 = 720 仍高过可视区,独立滚动条照样可见。
mod data;
mod demo;
mod detail;
mod launch_args;
// M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关)
mod mods;
// M235: 详情页的 SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
mod saves;
mod state;
mod text;
#[cfg(test)]
mod tests;
#[cfg(test)]
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 层建(`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,
catalog: unlucky_i18n::Catalog,
) -> String {
mods::failure_line(failure, Texts::of(catalog))
}
pub use state::{LaunchForm, Message as InstanceMessage};
pub(super) use text::Texts;
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::app::components::buttons::{self, Button};
use crate::app::components::interaction::Interaction;
use crate::app::components::navigation::list_item::{list_item, ListItem};
use crate::theme::Ui;
use unlucky_presenter::router::Route;
use super::frame::{self, Header};
use super::layout::{FrameMetrics, PageLayout};
use super::model::{Instances, Model, Row};
use super::{Action, Message};
/// 列表行距:**`None` = 这一层不加间距**(页面与护栏**共用这一个真源**)。
///
/// M102 的 ② 就是页级附加量:`ListItem` 自己的 60 高已含上下 8px 内边距,页面再叠一道
/// `Space::X1`(4) 就把行节奏变成 64、与可视高度除不尽(1180×760 下末行被切半行)。
/// 所以行距不写成字面量、也不散落在 `list_column` 里,而是这个常量——
/// 页面 `match` 它,`instances/tests.rs` 也从它算 pitch(改这里,测试立刻红)。
pub(super) const ROW_SPACING: Option<Space> = None;
/// 列表滚动区的**底部内缩**:让裁切线落在卡片的内边距带里,而不是压在 1px 描边上。
///
/// 与 [`ROW_SPACING`] 是一对(行距管「切在哪」、内缩管「切在什么底色上」),
/// 两个数都由 `instances/tests.rs` 的几何断言直接引用。
pub(super) const LIST_BOTTOM_INSET: Space = Space::X2;
/// 整页(`block.detail = None` 时详情区显示空态)。
///
/// 文案全部走 [`Texts`](`model.lang` 的语言):这个函数是实例页唯一的取词入口,
/// 页面里不留任何可见英文字面量(守门见 `instances/tests/i18n.rs`)。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Model,
form: &'a LaunchForm,
) -> Element<'a, Message> {
let block = &model.instances;
let forced = model.forced;
let texts = Texts::of(model.lang.catalog());
let header = Header::new(texts.title())
.subtitle(texts.subtitle())
.action(buttons::text(
ui,
forced_state(
Button::new(texts.new_instance(), Message::Action(Action::NewInstance)),
forced,
),
))
.action(buttons::text(
ui,
forced_state(
Button::new(texts.import(), Message::Action(Action::Import)),
forced,
),
));
let body = match metrics.layout {
PageLayout::ListDetail => frame::panes(
metrics,
list_column(ui, block, forced, texts),
match &block.detail {
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,
&model.saves,
forced,
texts,
),
None => frame::body(metrics, detail::detail_empty(ui, texts)),
},
};
frame::page(ui, metrics, header, body)
}
// --------------------------------------------------------------- 列表列
fn list_column<'a>(
ui: &'a Ui,
block: &'a Instances,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let head = container(
row![
container(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
texts.all_title(),
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role_owned(
ui,
TypeRole::LabelMedium,
false,
block.rows.len().to_string(),
ui.color(Role::OnSurfaceVariant),
),
]
.width(Length::Fill)
.align_y(Alignment::Center),
)
.width(Length::Fill)
.padding(Padding {
top: ui.space(Space::X3),
right: ui.space(Space::X3),
bottom: ui.space(Space::X1),
left: ui.space(Space::X3),
});
let mut rows = column(
block
.rows
.iter()
.map(|entry| list_row(ui, entry, is_selected(block, entry), forced))
.collect::<Vec<_>>(),
)
.padding(Padding::from([0.0, ui.space(Space::X2)]))
.width(Length::Fill);
// 行距只从 `ROW_SPACING` 来(当前是 None = 不加间距)。别在这里写字面量 `.spacing(…)`:
// 那正是视觉复审 ② 的那道页级附加量(`pages/tests.rs` 有一条源码结构断言盯着这段)。
if let Some(spacing) = ROW_SPACING {
rows = rows.spacing(ui.space(spacing));
}
// **独立滚动**:只有列表列这一条 scrollable,详情列不跟着滚。
let scroller = scrollable(rows)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui));
// 底部内缩:裁切线落在卡片的内边距带里,而不是压在圆角描边上(M102 / 视觉复审 ②)。
//
// M237:这一层画的是 `MaterialLayer::Card`(= `frame::BASE` 的同一个层),所以卡里的
// 列表行把底传 `frame::BASE`(见 `list_row`)——不另立常量,避免同一层出现两个「真源」。
container(column![head, scroller].height(Length::Fill))
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding::default().bottom(ui.space(LIST_BOTTOM_INSET)))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
fn is_selected(block: &Instances, entry: &Row) -> bool {
block
.detail
.as_ref()
.is_some_and(|detail| detail.id == entry.id)
}
/// `--force-state` 的落点:只改**画出来的**状态,不动任何状态机(cli `ForcedState` 的同一条纪律)。
pub(super) fn forced_state<'a, M>(
button: Button<'a, M>,
forced: Option<Interaction>,
) -> Button<'a, M> {
match forced {
Some(state) => button.state(state),
None => button,
}
}
/// 一行(`ListItem` 的两/三行档:overline = 运行状态,headline = 名字,supporting = 版本 + 加载器)。
///
/// 点一行 = **压一层路由栈**(`Route::Detail(id)`),页面自己不留「选中项」副本——
/// 于是「当前选中」与「返回栈」永远是同一份真相(`presenter::router`)。
fn list_row<'a>(
ui: &'a Ui,
entry: &'a Row,
selected: bool,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let mut item = ListItem::new(
entry.name.as_str(),
Message::Navigate(Route::Detail(entry.id.clone())),
)
.supporting(entry.supporting.as_str())
.leading(IconKind::Instances)
.trailing(IconKind::ChevronRight);
if let Some(status) = &entry.status {
item = item.overline(status.as_str());
}
if selected {
item = item.selected();
}
if let Some(state) = forced {
item = item.state(state);
}
// M237 复审 P2-1:列表卡画的是 `MaterialLayer::Card`(本文件 `list_column` 的 style),
// 行的 hover / 按下状态层按 `base.role()` 合成(`components/navigation/list_item.rs::palette`),
// 不给底就落回 `Base::CANVAS` —— 卡片里的行抬起时会叠在画布色上,与本轮修的禁用态同一类错。
item = item.base(frame::BASE);
list_item(ui, item)
}