feat(app-iced): 实例页「导入已有版本」向导(M223)
页头「导入」按钮(以前是占位动作,点了只打日志)现在打开四段向导: | 段 | 画什么 | |---|---| | Pick | 候选版本(可导入的才可点)+ 目标三档 chip + 实例名(缺省 InstanceDir) | | Preview | 生效运行目录(回程给的)+ 隔离档 + target_notes() 原文逐条 | | Failed | 稳定码 + problems 逐条(不折叠成一句「导入失败」) | | Done | 实例名 + 运行目录 + 提示原文 | - 向导是叠在页面之上的一层(遮罩 + 居中卡),不搅乱 list-detail 的版式判据; - 表单态在 App(app/adopt.rs 的 Form),事件真相在 presenter 的 AdoptState; - 在等回程时主按钮禁用(连点「确认」不会写两条实例); - i18n 中英目录 + 金表补 27 条向导文案(实例域末尾追加)。
This commit is contained in:
1 parent
5a59cb33b2
commit
41caae3571
16 files changed
+1265
-2
No files matched your search
@@ -0,0 +1,349 @@
|
||||
//! 「导入已有版本」向导(M223 GUIADOPT):实例页页头的入口 + 一张盖在页面上的向导卡。
|
||||
//!
|
||||
//! ## 四段(与 presenter 的 `AdoptState` 一一对应)
|
||||
//!
|
||||
//! | 段 | 画什么 | 从哪来 | 文件 |
|
||||
//! |---|---|---|---|
|
||||
//! | Pick | 候选版本 + 目标档 + 实例名 | `AdoptEvent::Scan` | [`pick`] |
|
||||
//! | Preview | 落点 / 隔离档 / **`target_notes()` 原文** | `AdoptEvent::Planned` | [`panes`] |
|
||||
//! | Failed | 稳定码 + 人读原因 + **`problems[]` 逐条** | `AdoptEvent::Failed` | [`panes`] |
|
||||
//! | Done | 实例名 / 运行目录 / 提示原文 | `AdoptEvent::Done` | [`panes`] |
|
||||
//!
|
||||
//! 「现在画哪一段」只有一处判据(presenter 的 `AdoptState::stage`),本文件只做投影。
|
||||
//!
|
||||
//! ## 三条纪律(契约 §5 的 1/2/3)
|
||||
//!
|
||||
//! 1. **运行目录不派生**:Preview / Done 上那个路径**原样**来自回程事件(缺省档可能带 `-2` 后缀,
|
||||
//! 只有 core 知道最后用了哪个)。本模块没有任何「拼一个目录名」的代码。
|
||||
//! 2. **后果话术不重写**:共享档 / 版本目录档的后果句在 Preview 段**逐条**画 `plan.notes`
|
||||
//! (core `target_notes()` 的原文)。Pick 段只放一句短提示(选之前就得知道会碰别人的目录),
|
||||
//! 真正的后果原文在确认按钮之前一定会出现。
|
||||
//! 3. **不折叠问题**:Failed 段把 `problems` 一条一行画出来,标题只说「导入失败」,原因全在下面。
|
||||
//!
|
||||
//! ## 为什么是「盖一层」而不是实例页里的一段
|
||||
//!
|
||||
//! 向导有四段状态、每段内容都不一样,塞进 list-detail 的正文列会把版式判据(424 散文宽、
|
||||
//! 列表独立滚动)搅乱。盖一层与 M187 的模态同一手法(遮罩 + 居中卡),但**不是**
|
||||
//! `DialogQueue` 那种「标题 + 正文 + 两个按钮」的窄形态:向导要画候选列表、单选档位与输入框。
|
||||
//!
|
||||
//! ## 表单字段归 `App`,事件真相归 presenter
|
||||
//!
|
||||
//! [`Form`] 只装「用户正在填的东西」(选中哪个版本、展示名、目标档、有没有在等回程);
|
||||
//! 候选、预检、失败、完成全部来自 `AppState.adopt`(presenter 的 `AdoptState`)。两边各管一半,
|
||||
//! 于是「画出来的落点」与「真正写盘的落点」永远是同一份事实。
|
||||
|
||||
mod panes;
|
||||
mod pick;
|
||||
mod text;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use iced::widget::{column, container, mouse_area, opaque, row, Space};
|
||||
use iced::{Alignment, Background, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space as Sp;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{AdoptCandidateView, AdoptStage, AdoptState, AdoptTargetView};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::feedback::dialog::scrim;
|
||||
use crate::pages::frame;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use self::text::{isolation_label, target_label, wrapped};
|
||||
use super::message::Message;
|
||||
|
||||
/// 向导卡宽度:比模态卡(560)宽一档——候选行要放「版本 id + 状态」,目标档是三枚 chip。
|
||||
const CARD_WIDTH: f32 = 640.0;
|
||||
|
||||
/// 候选列表 / 问题清单的滚动高(再多就滚,页脚按钮永远留在卡里)。
|
||||
const CARD_LIST_HEIGHT: f32 = 260.0;
|
||||
|
||||
/// 卡片的最大高(窗口更矮时整卡按窗口走:内容在中间区滚)。
|
||||
const CARD_MAX_HEIGHT: f32 = 560.0;
|
||||
|
||||
/// 正在等哪一条命令的回程。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Busy {
|
||||
/// 空闲(可以点)
|
||||
#[default]
|
||||
Idle,
|
||||
/// 正在扫描 `versions/`
|
||||
Scanning,
|
||||
/// 正在预检(零写盘)
|
||||
Checking,
|
||||
/// 正在真正登记
|
||||
Importing,
|
||||
}
|
||||
|
||||
impl Busy {
|
||||
/// 有没有在等回程。
|
||||
#[must_use]
|
||||
pub const fn waiting(self) -> bool {
|
||||
!matches!(self, Self::Idle)
|
||||
}
|
||||
}
|
||||
|
||||
/// 向导的表单态(**归 `App`**,页面借一帧)。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Form {
|
||||
/// 面板开着吗
|
||||
pub open: bool,
|
||||
/// 选中的版本 id(`None` = 还没选)
|
||||
pub selected: Option<String>,
|
||||
/// 展示名(空 = 用版本 id)
|
||||
pub name: String,
|
||||
/// 运行目录档(缺省 [`AdoptTargetView::InstanceDir`],**不是** isolated)
|
||||
pub target: AdoptTargetView,
|
||||
/// 在等在飞的那一条命令
|
||||
pub busy: Busy,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
/// 打开面板(回到 Pick 段、清掉上一轮的选中与名字)。
|
||||
pub fn open(&mut self) {
|
||||
*self = Self {
|
||||
open: true,
|
||||
busy: Busy::Scanning,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
|
||||
/// 关掉面板(全清)。
|
||||
pub fn close(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
/// 回程到了:不在等任何东西了。
|
||||
pub fn settled(&mut self) {
|
||||
self.busy = Busy::Idle;
|
||||
}
|
||||
|
||||
/// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。
|
||||
#[must_use]
|
||||
pub fn candidate<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptCandidateView> {
|
||||
let id = self.selected.as_deref()?;
|
||||
adopt_state
|
||||
.scan
|
||||
.as_ref()?
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.version_id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 向导消息(`crate::app::Message::Adopt` 的载荷)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AdoptMessage {
|
||||
/// 打开向导(实例页页头「导入」)
|
||||
Open,
|
||||
/// 关掉(点遮罩 / 关闭按钮)
|
||||
Close,
|
||||
/// 选中一个候选版本
|
||||
Select(String),
|
||||
/// 改展示名
|
||||
Name(String),
|
||||
/// 改运行目录档
|
||||
Target(AdoptTargetView),
|
||||
/// 预检(零写盘)
|
||||
Check,
|
||||
/// 确认登记(唯一写盘入口)
|
||||
Confirm,
|
||||
/// 从 Preview / Failed 退回候选列表(等价于重新扫一次)
|
||||
Back,
|
||||
}
|
||||
|
||||
/// 向导消息 → app 消息。
|
||||
fn adopt(message: AdoptMessage) -> Message {
|
||||
Message::Adopt(message)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 视图
|
||||
|
||||
/// 把向导叠在页面之上(没打开时原样返回页面树)。
|
||||
pub(super) fn layer<'a>(
|
||||
ui: &'a Ui,
|
||||
base: Element<'a, Message>,
|
||||
form: &'a Form,
|
||||
adopt_state: &'a AdoptState,
|
||||
lang: Lang,
|
||||
) -> Element<'a, Message> {
|
||||
if !form.open {
|
||||
return base;
|
||||
}
|
||||
let catalog = lang.catalog();
|
||||
let backdrop = mouse_area(
|
||||
container(Space::new())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(scrim(ui))),
|
||||
..container::Style::default()
|
||||
}),
|
||||
)
|
||||
.on_press(adopt(AdoptMessage::Close));
|
||||
|
||||
// 透明的一层只负责居中;吃点击的 `opaque` 只包卡片(点卡内不关,点遮罩才关)。
|
||||
let centered = container(opaque(card(ui, form, adopt_state, catalog)))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(ui.space(Sp::X6));
|
||||
|
||||
iced::widget::stack![base, backdrop, centered].into()
|
||||
}
|
||||
|
||||
/// 卡片本体:标题 + 一句说明 + 中间(按段) + 页脚按钮。
|
||||
fn card<'a>(
|
||||
ui: &'a Ui,
|
||||
form: &'a Form,
|
||||
adopt_state: &'a AdoptState,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
let body = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::HeadlineSmall,
|
||||
true,
|
||||
title_text(adopt_state, catalog),
|
||||
ui.color(Role::OnSurface),
|
||||
)
|
||||
.map(Message::Page),
|
||||
wrapped(
|
||||
ui,
|
||||
subtitle(adopt_state, catalog),
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
),
|
||||
frame::divider(ui).map(Message::Page),
|
||||
middle(ui, form, adopt_state, catalog),
|
||||
frame::divider(ui).map(Message::Page),
|
||||
footer(ui, form, adopt_state, catalog),
|
||||
]
|
||||
.spacing(ui.space(Sp::X3))
|
||||
.width(Length::Fill);
|
||||
|
||||
container(body)
|
||||
.width(Length::Fixed(CARD_WIDTH))
|
||||
.max_height(CARD_MAX_HEIGHT)
|
||||
.padding(ui.space(Sp::X6))
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
|
||||
border: ui.outlined(ShapeScale::ExtraLarge),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 段标题(四段各一句)。
|
||||
fn title_text(adopt_state: &AdoptState, catalog: Catalog) -> &'static str {
|
||||
match adopt_state.stage() {
|
||||
AdoptStage::Pick => catalog.text(Key::AdoptTitle),
|
||||
AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle),
|
||||
AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle),
|
||||
AdoptStage::Done => catalog.text(Key::AdoptDoneTitle),
|
||||
}
|
||||
}
|
||||
|
||||
/// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。
|
||||
fn subtitle(adopt_state: &AdoptState, catalog: Catalog) -> String {
|
||||
match adopt_state.stage() {
|
||||
AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(),
|
||||
AdoptStage::Preview => adopt_state
|
||||
.plan
|
||||
.as_ref()
|
||||
.map(|plan| {
|
||||
format!(
|
||||
"{} · {}",
|
||||
isolation_label(plan.isolation, catalog),
|
||||
target_label(plan.target, catalog)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
AdoptStage::Failed => adopt_state
|
||||
.failure
|
||||
.as_ref()
|
||||
.map(|failure| failure.message.clone())
|
||||
.unwrap_or_default(),
|
||||
AdoptStage::Done => catalog.text(Key::AdoptDoneHint).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 中间区(按段交给 `pick` / `panes`)。
|
||||
fn middle<'a>(
|
||||
ui: &'a Ui,
|
||||
form: &'a Form,
|
||||
adopt_state: &'a AdoptState,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
match adopt_state.stage() {
|
||||
AdoptStage::Pick => pick::pick(ui, form, adopt_state.scan.as_ref(), catalog),
|
||||
AdoptStage::Preview => panes::preview(ui, adopt_state.plan.as_ref(), catalog),
|
||||
AdoptStage::Failed => panes::failed(ui, adopt_state.failure.as_ref(), catalog),
|
||||
AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog),
|
||||
}
|
||||
}
|
||||
|
||||
/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。
|
||||
fn footer<'a>(
|
||||
ui: &'a Ui,
|
||||
form: &'a Form,
|
||||
adopt_state: &'a AdoptState,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
let stage = adopt_state.stage();
|
||||
let busy = form.busy.waiting();
|
||||
|
||||
let primary: Element<'a, Message> = match stage {
|
||||
AdoptStage::Pick => {
|
||||
let ready = form.candidate(adopt_state).is_some_and(|c| c.adoptable()) && !busy;
|
||||
if ready {
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(catalog.text(Key::AdoptPreview), adopt(AdoptMessage::Check)),
|
||||
)
|
||||
} else {
|
||||
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptPreview)))
|
||||
}
|
||||
}
|
||||
AdoptStage::Preview if !busy => buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AdoptConfirm),
|
||||
adopt(AdoptMessage::Confirm),
|
||||
),
|
||||
),
|
||||
AdoptStage::Preview => {
|
||||
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptConfirm)))
|
||||
}
|
||||
// 失败 / 完成:主按钮就是关掉(Done 不用「去列表看」——实例已经在列表里了)
|
||||
AdoptStage::Failed | AdoptStage::Done => buttons::filled(
|
||||
ui,
|
||||
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
|
||||
),
|
||||
};
|
||||
|
||||
let mut actions = row![].spacing(ui.space(Sp::X2)).width(Length::Fill);
|
||||
// Preview / Failed 给一条「退回候选列表」(= 重新扫一次,代价只有一次读目录)
|
||||
if matches!(stage, AdoptStage::Preview | AdoptStage::Failed) {
|
||||
actions = actions.push(buttons::text(
|
||||
ui,
|
||||
Button::new(catalog.text(Key::AdoptBack), adopt(AdoptMessage::Back)),
|
||||
));
|
||||
}
|
||||
actions = actions.push(container(Space::new()).width(Length::Fill));
|
||||
if !matches!(stage, AdoptStage::Done) {
|
||||
actions = actions.push(buttons::text(
|
||||
ui,
|
||||
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
|
||||
));
|
||||
}
|
||||
actions = actions.push(primary);
|
||||
|
||||
column![actions].width(Length::Fill).into()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Preview / Failed / Done 三段。
|
||||
//!
|
||||
//! | 段 | 画什么 | 判据 |
|
||||
//! |---|---|---|
|
||||
//! | Preview | 生效运行目录(**回程给的**)+ 隔离档 + `notes` 原文逐条 | `AdoptEvent::Planned` |
|
||||
//! | Failed | 稳定码 + `problems[]` 逐条 | `AdoptEvent::Failed` |
|
||||
//! | Done | 实例名 + 运行目录 + `notes` 原文逐条 | `AdoptEvent::Done` |
|
||||
//!
|
||||
//! 三段都不自己算路径、不自己写后果句:`run_dir` 与 `notes` 是 core 的返回值原样。
|
||||
//! 缺省档会带 `-2`/`-3` 后缀(两个不同展示名可能落到同一个物理目录),**只有 core 知道**
|
||||
//! 最后用了哪个——页面对它再做一次派生就会与真正启动时用的目录分叉。
|
||||
|
||||
use iced::widget::{column, scrollable};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space as Sp;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{AdoptDoneView, AdoptFailureView, AdoptPlanView};
|
||||
|
||||
use crate::pages::frame;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::text::{field_line, isolation_label, mono, problem_line, wrapped};
|
||||
use super::CARD_LIST_HEIGHT;
|
||||
|
||||
/// 预检结果:落点 + 隔离档 + 后果原文。
|
||||
pub(super) fn preview<'a>(
|
||||
ui: &'a Ui,
|
||||
plan: Option<&'a AdoptPlanView>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(plan) = plan else {
|
||||
return wrapped(
|
||||
ui,
|
||||
catalog.text(Key::AdoptScanning).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
);
|
||||
};
|
||||
let mut body = column![
|
||||
field_line(
|
||||
ui,
|
||||
catalog.text(Key::AdoptPlanRunDir),
|
||||
&plan.run_dir.display().to_string()
|
||||
),
|
||||
field_line(
|
||||
ui,
|
||||
catalog.text(Key::AdoptPlanIsolation),
|
||||
&isolation_label(plan.isolation, catalog),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Sp::X2))
|
||||
.width(Length::Fill);
|
||||
|
||||
// 后果话术:core `target_notes()` 的原文,一条一行、一个字不改
|
||||
for note in &plan.notes {
|
||||
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
body.into()
|
||||
}
|
||||
|
||||
/// 失败:稳定码 + **problems 逐条**。
|
||||
pub(super) fn failed<'a>(
|
||||
ui: &'a Ui,
|
||||
failure: Option<&'a AdoptFailureView>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(failure) = failure else {
|
||||
return wrapped(
|
||||
ui,
|
||||
catalog.text(Key::AdoptScanning).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
);
|
||||
};
|
||||
let mut body = column![mono(ui, failure.code.clone())]
|
||||
.spacing(ui.space(Sp::X2))
|
||||
.width(Length::Fill);
|
||||
|
||||
if failure.problems.is_empty() {
|
||||
body = body.push(wrapped(
|
||||
ui,
|
||||
failure.message.clone(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
return body.into();
|
||||
}
|
||||
body = body.push(wrapped(
|
||||
ui,
|
||||
catalog.text(Key::AdoptProblemCount).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
for problem in &failure.problems {
|
||||
body = body.push(problem_line(ui, problem));
|
||||
}
|
||||
scrollable(body)
|
||||
.height(Length::Fixed(CARD_LIST_HEIGHT))
|
||||
.style(frame::scroll_style(ui))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 完成:实例名 + 运行目录 + 提示原文。
|
||||
pub(super) fn done<'a>(
|
||||
ui: &'a Ui,
|
||||
done: Option<&'a AdoptDoneView>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(done) = done else {
|
||||
return wrapped(ui, String::new(), ui.color(Role::OnSurfaceVariant));
|
||||
};
|
||||
let mut body = column![
|
||||
field_line(ui, catalog.text(Key::AdoptTitle), &done.name),
|
||||
field_line(
|
||||
ui,
|
||||
catalog.text(Key::AdoptPlanRunDir),
|
||||
&done.run_dir.display().to_string()
|
||||
),
|
||||
field_line(
|
||||
ui,
|
||||
catalog.text(Key::AdoptPlanIsolation),
|
||||
&isolation_label(done.isolation, catalog),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Sp::X2))
|
||||
.width(Length::Fill);
|
||||
|
||||
for note in &done.notes {
|
||||
body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
body.into()
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Pick 段:候选列表 + 目标档 + 实例名。
|
||||
//!
|
||||
//! **可导入的候选才可点**:不可导入的行把 `problems` 逐条列在下面但不接点击——点它只会换来
|
||||
//! 一条注定被 core 拒绝的命令(`adopt_not_adoptable`),而用户从行上已经看到了原因。
|
||||
//!
|
||||
//! 目标档三枚 chip 的**短提示**在选中时立刻换(共享/版本目录档用 `Error` 色);
|
||||
//! **后果原文**不在这里——它在 Preview 段逐条画 `plan.notes`(core `target_notes()` 的原文)。
|
||||
|
||||
use iced::widget::{button, column, container, row, scrollable};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space as Sp;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{AdoptCandidateView, AdoptScanView, AdoptTargetView};
|
||||
|
||||
use crate::app::components::inputs::{text_field, Field};
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::pages::frame;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::text::{label, problem_line, target_label, wrapped};
|
||||
use super::{adopt, AdoptMessage, Form, CARD_LIST_HEIGHT};
|
||||
|
||||
/// Pick 段整体。
|
||||
pub(super) fn pick<'a>(
|
||||
ui: &'a Ui,
|
||||
form: &'a Form,
|
||||
scan: Option<&'a AdoptScanView>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(scan) = scan else {
|
||||
// 扫描还没回来:一行进度文案(不做假进度条)
|
||||
return wrapped(
|
||||
ui,
|
||||
catalog.text(Key::AdoptScanning).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
);
|
||||
};
|
||||
|
||||
let mut body = column![].spacing(ui.space(Sp::X3)).width(Length::Fill);
|
||||
if scan.is_empty() {
|
||||
let dir = scan.versions_dir.display().to_string();
|
||||
body = body.push(wrapped(
|
||||
ui,
|
||||
catalog.render(Key::AdoptEmpty, &[("dir", &dir)]),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
} else {
|
||||
body = body.push(label(ui, catalog.text(Key::AdoptSelectLabel)));
|
||||
body = body.push(candidate_list(ui, form, scan, catalog));
|
||||
}
|
||||
|
||||
body = body.push(frame::divider(ui).map(Message::Page));
|
||||
body = body.push(label(ui, catalog.text(Key::AdoptTargetLabel)));
|
||||
body = body.push(target_row(ui, form, catalog));
|
||||
body = body.push(text_field(
|
||||
ui,
|
||||
Field::new(form.name.as_str()).label(catalog.text(Key::AdoptNameLabel)),
|
||||
|value| adopt(AdoptMessage::Name(value)),
|
||||
));
|
||||
body.into()
|
||||
}
|
||||
|
||||
/// 候选列表(可滚,定高:列表再长页脚按钮也留在卡里)。
|
||||
fn candidate_list<'a>(
|
||||
ui: &'a Ui,
|
||||
form: &'a Form,
|
||||
scan: &'a AdoptScanView,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
let rows = column(
|
||||
scan.candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate_row(ui, form, candidate, catalog))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(ui.space(Sp::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
scrollable(rows)
|
||||
.height(Length::Fixed(CARD_LIST_HEIGHT))
|
||||
.style(frame::scroll_style(ui))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一条候选。
|
||||
fn candidate_row<'a>(
|
||||
ui: &'a Ui,
|
||||
form: &'a Form,
|
||||
candidate: &'a AdoptCandidateView,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
let selected = form.selected.as_deref() == Some(candidate.version_id.as_str());
|
||||
|
||||
let mut items = column![row![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyLarge,
|
||||
selected,
|
||||
candidate.version_id.as_str(),
|
||||
ui.color(Role::OnSurface),
|
||||
)
|
||||
.map(Message::Page),
|
||||
container(status(ui, candidate, selected, catalog))
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::End),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)]
|
||||
.spacing(ui.space(Sp::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
// 问题逐条(不折叠):候选阶段就把「为什么不能导入」说清楚
|
||||
for problem in &candidate.problems {
|
||||
items = items.push(problem_line(ui, problem));
|
||||
}
|
||||
|
||||
let body = container(items)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.space(Sp::X2))
|
||||
.style(move |_theme| container::Style {
|
||||
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
|
||||
border: Border {
|
||||
radius: ui.radius_dp(ShapeScale::Medium),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
});
|
||||
|
||||
if !candidate.adoptable() {
|
||||
// 不可导入的行**不可点**(也就不存在「点了才被告知不行」这条路)
|
||||
return body.into();
|
||||
}
|
||||
button(body)
|
||||
.width(Length::Fill)
|
||||
.padding(0)
|
||||
.style(|_theme, _status| button::Style {
|
||||
background: None,
|
||||
text_color: Color::TRANSPARENT,
|
||||
border: Border::default(),
|
||||
shadow: iced::Shadow::default(),
|
||||
snap: true,
|
||||
})
|
||||
.on_press(adopt(AdoptMessage::Select(candidate.version_id.clone())))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 候选右侧那枚状态(可导入 / N 个问题 / 已被某实例使用)。
|
||||
fn status<'a>(
|
||||
ui: &'a Ui,
|
||||
candidate: &AdoptCandidateView,
|
||||
selected: bool,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
if !candidate.adoptable() {
|
||||
let count = candidate.problems.len().to_string();
|
||||
return wrapped(
|
||||
ui,
|
||||
catalog.render(Key::AdoptBlocked, &[("count", &count)]),
|
||||
ui.color(Role::Error),
|
||||
);
|
||||
}
|
||||
if let Some(name) = candidate.registered.first() {
|
||||
return wrapped(
|
||||
ui,
|
||||
catalog.render(Key::AdoptRegistered, &[("name", name)]),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
);
|
||||
}
|
||||
if selected {
|
||||
return chip(ui, Chip::current(catalog.text(Key::AdoptReady)));
|
||||
}
|
||||
wrapped(
|
||||
ui,
|
||||
catalog.text(Key::AdoptReady).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/// 三枚目标 chip(缺省档在最前;共享 / 版本目录档必须**明确点选**)。
|
||||
fn target_row<'a>(ui: &'a Ui, form: &'a Form, catalog: Catalog) -> Element<'a, Message> {
|
||||
let chips = AdoptTargetView::ALL
|
||||
.into_iter()
|
||||
.map(|target| {
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
target_label(target, catalog),
|
||||
form.target == target,
|
||||
adopt(AdoptMessage::Target(target)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
column![
|
||||
row(chips).spacing(ui.space(Sp::X2)).width(Length::Fill),
|
||||
target_hint(ui, form.target, catalog),
|
||||
]
|
||||
.spacing(ui.space(Sp::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 目标档的一句话说明(点选后立刻换)。**不是后果原文**:原文在 Preview 段逐条画
|
||||
/// `plan.notes`(core `target_notes()`)。
|
||||
fn target_hint<'a>(ui: &'a Ui, target: AdoptTargetView, catalog: Catalog) -> Element<'a, Message> {
|
||||
let key = match target {
|
||||
AdoptTargetView::InstanceDir => Key::AdoptTargetInstanceDirHint,
|
||||
AdoptTargetView::Shared => Key::AdoptTargetSharedHint,
|
||||
AdoptTargetView::VersionDir => Key::AdoptTargetVersionDirHint,
|
||||
};
|
||||
let color = if target.touches_foreign_dir() {
|
||||
ui.color(Role::Error)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
wrapped(ui, catalog.text(key).to_string(), color)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! `app::adopt` 的用态用例(M223):表单态 + 四段投影。
|
||||
//!
|
||||
//! 抓得住:
|
||||
//! - `Form` 的生命周期(open 清上一轮 / close 全清 / settled 解除等待);
|
||||
//! - `candidate()` 在选中项不在列表里时返回 `None`(界面自动退回「请选择」);
|
||||
//! - 四段的文案真源(标题/后果原文/问题逐条)能从 `AdoptState` 投影出来。
|
||||
//!
|
||||
//! 抓不住:**像素**(那是 D30 的截图通道);也不抓 core 的判据(那是 `host/adopt/tests.rs`)。
|
||||
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView,
|
||||
AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptStage, AdoptState, AdoptTargetView,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
|
||||
AdoptCandidateView {
|
||||
version_id: version_id.to_string(),
|
||||
jar_present: true,
|
||||
inherits: Vec::new(),
|
||||
registered: Vec::new(),
|
||||
problems,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_with_scan() -> AdoptState {
|
||||
let mut state = AdoptState::default();
|
||||
state.apply(AdoptEvent::Scan(Box::new(AdoptScanView {
|
||||
versions_dir: std::path::PathBuf::from("/game/versions"),
|
||||
candidates: vec![candidate("1.20.1", Vec::new())],
|
||||
})));
|
||||
state
|
||||
}
|
||||
|
||||
/// 打开 ⇒ 关闭:上一轮的选中与名字都要清掉(否则下次打开画的是上一次的草稿)。
|
||||
#[test]
|
||||
fn open_and_close_reset_the_form() {
|
||||
let mut form = Form::default();
|
||||
form.open();
|
||||
assert!(form.open);
|
||||
assert_eq!(form.busy, Busy::Scanning);
|
||||
assert_eq!(form.target, AdoptTargetView::InstanceDir);
|
||||
|
||||
form.selected = Some("1.20.1".to_string());
|
||||
form.name = "生存".to_string();
|
||||
form.close();
|
||||
assert!(!form.open);
|
||||
assert!(form.selected.is_none());
|
||||
assert!(form.name.is_empty());
|
||||
|
||||
// 再开一次也不该带着上一轮的选中项
|
||||
form.open();
|
||||
assert!(form.selected.is_none());
|
||||
}
|
||||
|
||||
/// `settled` 只解除等待,不动用户填的东西(回程不该清表单)。
|
||||
#[test]
|
||||
fn settling_keeps_what_the_user_typed() {
|
||||
let mut form = Form::default();
|
||||
form.open();
|
||||
form.selected = Some("1.20.1".to_string());
|
||||
form.name = "生存".to_string();
|
||||
form.settled();
|
||||
assert_eq!(form.busy, Busy::Idle);
|
||||
assert_eq!(form.selected.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(form.name, "生存");
|
||||
}
|
||||
|
||||
/// 选中项不在候选列表里 ⇒ `candidate()` 是 `None`(界面不能对着一个不存在的版本画「预检」)。
|
||||
#[test]
|
||||
fn candidate_is_none_when_the_selection_vanished() {
|
||||
let state = state_with_scan();
|
||||
let mut form = Form {
|
||||
selected: Some("1.20.1".to_string()),
|
||||
..Form::default()
|
||||
};
|
||||
assert!(form.candidate(&state).is_some());
|
||||
form.selected = Some("9.9.9".to_string());
|
||||
assert!(form.candidate(&state).is_none());
|
||||
}
|
||||
|
||||
/// 四段的标题与后果原文都从目录来(不写死英文字面量、不重写 core 的后果句)。
|
||||
#[test]
|
||||
fn the_four_stages_project_from_the_event_state() {
|
||||
let catalog = Lang::ZhCn.catalog();
|
||||
let mut state = state_with_scan();
|
||||
|
||||
// Pick
|
||||
assert_eq!(state.stage(), AdoptStage::Pick);
|
||||
assert_eq!(
|
||||
title_text(&state, catalog),
|
||||
catalog.text(Key::AdoptTitle),
|
||||
"标题走目录"
|
||||
);
|
||||
assert!(subtitle(&state, catalog).contains("不复制"));
|
||||
|
||||
// Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改
|
||||
let note =
|
||||
"运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string();
|
||||
state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView {
|
||||
version_id: "1.20.1".to_string(),
|
||||
name: "1.20.1".to_string(),
|
||||
target: AdoptTargetView::Shared,
|
||||
isolation: AdoptIsolationView::Shared,
|
||||
run_dir: std::path::PathBuf::from("/game"),
|
||||
notes: vec![note.clone()],
|
||||
})));
|
||||
assert_eq!(state.stage(), AdoptStage::Preview);
|
||||
assert_eq!(
|
||||
state.plan.as_ref().map(|plan| plan.notes.first().cloned()),
|
||||
Some(Some(note.clone())),
|
||||
"后果原文必须原样留着"
|
||||
);
|
||||
|
||||
// Failed:problems 逐条(不折叠)
|
||||
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
|
||||
code: "adopt_not_adoptable".to_string(),
|
||||
message: "版本 1.19.4 不能登记".to_string(),
|
||||
problems: vec![
|
||||
AdoptProblemView {
|
||||
code: "manifest_missing".to_string(),
|
||||
message: "没有清单".to_string(),
|
||||
},
|
||||
AdoptProblemView {
|
||||
code: "missing_parent".to_string(),
|
||||
message: "缺父版本 1.20.1".to_string(),
|
||||
},
|
||||
],
|
||||
})));
|
||||
assert_eq!(state.stage(), AdoptStage::Failed);
|
||||
assert_eq!(
|
||||
state.failure.as_ref().map(|f| f.problems.len()),
|
||||
Some(2),
|
||||
"两条问题一条都不许吞"
|
||||
);
|
||||
assert_eq!(
|
||||
title_text(&state, catalog),
|
||||
catalog.text(Key::AdoptFailureTitle)
|
||||
);
|
||||
|
||||
// Done
|
||||
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
|
||||
instance_id: "id-1".to_string(),
|
||||
name: "1.20.1".to_string(),
|
||||
version_id: "1.20.1".to_string(),
|
||||
isolation: AdoptIsolationView::Shared,
|
||||
run_dir: std::path::PathBuf::from("/game"),
|
||||
dir_created: false,
|
||||
notes: vec![note],
|
||||
})));
|
||||
assert_eq!(state.stage(), AdoptStage::Done);
|
||||
assert_eq!(
|
||||
title_text(&state, catalog),
|
||||
catalog.text(Key::AdoptDoneTitle)
|
||||
);
|
||||
}
|
||||
|
||||
/// 中英两套目录的向导文案都不是占位(`text()` 回落到 key 名就是这个用例要抓的假绿)。
|
||||
#[test]
|
||||
fn both_catalogs_have_the_wizard_wording() {
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
let catalog = lang.catalog();
|
||||
for key in [
|
||||
Key::AdoptTitle,
|
||||
Key::AdoptSubtitle,
|
||||
Key::AdoptScanning,
|
||||
Key::AdoptEmpty,
|
||||
Key::AdoptSelectLabel,
|
||||
Key::AdoptReady,
|
||||
Key::AdoptTargetLabel,
|
||||
Key::AdoptTargetInstanceDir,
|
||||
Key::AdoptTargetShared,
|
||||
Key::AdoptTargetVersionDir,
|
||||
Key::AdoptNameLabel,
|
||||
Key::AdoptPreview,
|
||||
Key::AdoptConfirm,
|
||||
Key::AdoptBack,
|
||||
Key::AdoptClose,
|
||||
Key::AdoptPlanTitle,
|
||||
Key::AdoptPlanRunDir,
|
||||
Key::AdoptPlanIsolation,
|
||||
Key::AdoptFailureTitle,
|
||||
Key::AdoptProblemCount,
|
||||
Key::AdoptDoneTitle,
|
||||
Key::AdoptDoneHint,
|
||||
] {
|
||||
let value = catalog.text(key);
|
||||
assert!(!value.is_empty(), "{key:?} 在 {lang:?} 是空的");
|
||||
assert_ne!(
|
||||
value,
|
||||
format!("{key:?}"),
|
||||
"{key:?} 在 {lang:?} 没进目录(回落成了 key 名)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// 共享档的短提示必须把「会写进别人的 .minecraft」说清楚(选之前就得知道)
|
||||
let hint = Lang::En.catalog().text(Key::AdoptTargetSharedHint);
|
||||
assert!(hint.contains(".minecraft"), "{hint}");
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! 向导里的小工具:等宽行、可换行正文、标签、问题行、档位人话。
|
||||
//!
|
||||
//! 单独一个文件只为守 400 行硬线(AGENTS §3.1):三段视图(`pick` / `panes`)与卡壳
|
||||
//! (`adopt.rs`)都要用它们,放哪儿都会让那一处越线。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{column, text};
|
||||
use iced::{Color, Element, Font, Length, Pixels};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{AdoptIsolationView, AdoptProblemView, AdoptTargetView};
|
||||
|
||||
use crate::pages::frame;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::message::Message;
|
||||
|
||||
/// 段内的次级标签(`LabelMedium` + `OnSurfaceVariant`)。
|
||||
pub(super) fn label<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
value,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
.map(Message::Page)
|
||||
}
|
||||
|
||||
/// 等宽一行(路径 / 机读码要能逐字读)。
|
||||
pub(super) fn mono<'a>(ui: &'a Ui, value: String) -> Element<'a, Message> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
text(value)
|
||||
.size(style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||
.font(Font {
|
||||
family: iced::font::Family::Name(ui.families().mono),
|
||||
..Font::DEFAULT
|
||||
})
|
||||
.color(ui.color(Role::OnSurface))
|
||||
.width(Length::Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一段可换行的正文(长路径只按词折行会把卡顶破,所以按词或字形折)。
|
||||
pub(super) fn wrapped<'a>(ui: &'a Ui, value: String, color: Color) -> Element<'a, Message> {
|
||||
frame::text_role_owned(ui, TypeRole::BodySmall, false, value, color).map(Message::Page)
|
||||
}
|
||||
|
||||
/// 「标签 + 值」一行(值用等宽)。
|
||||
pub(super) fn field_line<'a>(ui: &'a Ui, label_text: &'a str, value: &str) -> Element<'a, Message> {
|
||||
column![label(ui, label_text), mono(ui, value.to_string())]
|
||||
.spacing(0.0)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一条问题:稳定码(等宽) + 人读原因(`Error` 色)。**一条都不许折叠**。
|
||||
pub(super) fn problem_line<'a>(ui: &'a Ui, problem: &'a AdoptProblemView) -> Element<'a, Message> {
|
||||
column![
|
||||
mono(ui, problem.code.clone()),
|
||||
wrapped(ui, problem.message.clone(), ui.color(Role::Error)),
|
||||
]
|
||||
.spacing(0.0)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 隔离档的人话(沿用实例页已有的两条;`custom` 就是缺省档,直接说「在 ULL 数据区新建」)。
|
||||
pub(super) fn isolation_label(isolation: AdoptIsolationView, catalog: Catalog) -> String {
|
||||
match isolation {
|
||||
AdoptIsolationView::Isolated => catalog.text(Key::InstancesIsolationIsolated).to_string(),
|
||||
AdoptIsolationView::Shared => catalog.text(Key::InstancesIsolationShared).to_string(),
|
||||
AdoptIsolationView::Custom => catalog.text(Key::AdoptTargetInstanceDir).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 目标档的短标签。
|
||||
pub(super) fn target_label(target: AdoptTargetView, catalog: Catalog) -> &'static str {
|
||||
match target {
|
||||
AdoptTargetView::InstanceDir => catalog.text(Key::AdoptTargetInstanceDir),
|
||||
AdoptTargetView::Shared => catalog.text(Key::AdoptTargetShared),
|
||||
AdoptTargetView::VersionDir => catalog.text(Key::AdoptTargetVersionDir),
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,11 @@ pub enum Message {
|
||||
Demo(components::gallery::DemoMessage),
|
||||
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
|
||||
Dialog(super::dialog::Message),
|
||||
/// 导入已有版本向导(M223):打开 / 选版本 / 选目标 / 预检 / 确认 / 关闭。
|
||||
///
|
||||
/// 落点是 `app/update/adopt.rs`。页面只发一条入口意图
|
||||
/// (`pages::Message::Action(pages::Action::Import)`),向导自己的四段状态都在这里收口。
|
||||
Adopt(super::adopt::AdoptMessage),
|
||||
}
|
||||
|
||||
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
|
||||
//! (组件源码里的文档与测试都按这个路径写)。
|
||||
|
||||
mod adopt;
|
||||
mod appearance;
|
||||
mod backend;
|
||||
mod boot;
|
||||
|
||||
@@ -196,6 +196,11 @@ pub struct App {
|
||||
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
|
||||
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
|
||||
pub(super) launch_form: pages::LaunchForm,
|
||||
/// 实例页「导入已有版本」向导的表单态(M223):选中版本 / 展示名 / 目标档 / 在等回程。
|
||||
///
|
||||
/// 住在 `App` 的理由与 `launch_form` 一致:向导跨帧(扫描 → 选 → 预检 → 确认),
|
||||
/// 而候选/预检/失败/完成这些**事件真相**在 `AppState.adopt`(presenter),不在这里。
|
||||
pub(super) adopt_form: super::adopt::Form,
|
||||
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
|
||||
///
|
||||
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
|
||||
|
||||
@@ -136,6 +136,7 @@ impl App {
|
||||
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
|
||||
download_draft: pages::InstallDraft::default(),
|
||||
launch_form: pages::LaunchForm::default(),
|
||||
adopt_form: super::super::adopt::Form::default(),
|
||||
install_cancelled_seen: 0,
|
||||
launch_ends_seen: 0,
|
||||
dialog_card: None,
|
||||
|
||||
@@ -46,6 +46,8 @@ use super::state::{App, IDLE_TICK, TICK, WATCH_WINDOW};
|
||||
use super::CAPTURE_AT_FRAME;
|
||||
|
||||
mod account;
|
||||
// M223:导入已有版本向导(扫描 / 预检 / 确认 / 关闭 / 退回)
|
||||
mod adopt;
|
||||
mod agent;
|
||||
mod appearance;
|
||||
pub(super) mod boot_env;
|
||||
@@ -149,6 +151,8 @@ impl App {
|
||||
}
|
||||
Message::Page(message) => self.on_page(message),
|
||||
Message::Dialog(message) => dialog::on_dialog(self, message),
|
||||
// M223:导入已有版本向导(打开 / 选版本 / 选目标 / 预检 / 确认 / 退回 / 关闭)
|
||||
Message::Adopt(message) => self.on_adopt(message),
|
||||
Message::Agent(message) => {
|
||||
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
|
||||
if let crate::shell::agent::Message::Key(event) = &message {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! 「导入已有版本」向导的落点(M223 GUIADOPT)。
|
||||
//!
|
||||
//! ## 四段各自发什么、等什么
|
||||
//!
|
||||
//! | 用户动作 | 发什么 | 等什么回程 |
|
||||
//! |---|---|---|
|
||||
//! | 页头「导入」/ [`AdoptMessage::Open`] | `AdoptCommand::Scan` | `AdoptEvent::Scan` |
|
||||
//! | 选目标 + 点「预检」/ [`AdoptMessage::Check`] | `AdoptCommand::Preview` | `Planned` / `Failed` |
|
||||
//! | 点「确认导入」/ [`AdoptMessage::Confirm`] | `AdoptCommand::Confirm` | `Done` / `Failed` |
|
||||
//! | 「返回」/ [`AdoptMessage::Back`] | `AdoptCommand::Scan`(重扫一次,顺手清掉预检/失败) | `Scan` |
|
||||
//! | 「关闭」/ [`AdoptMessage::Close`] | `AdoptCommand::Dismiss` | `Cleared` |
|
||||
//!
|
||||
//! ## 三段纪律
|
||||
//!
|
||||
//! 1. **不在本地判「能不能导入」**:候选可不可用、落点在哪、有哪些问题全部来自 presenter 的回程
|
||||
//! (`AdoptState`)。这里只把「用户点了什么」翻成命令;
|
||||
//! 2. **确认只发用户填过的那份请求**(版本 + 名字 + 目标档),预检与确认**同一份**——
|
||||
//! 于是「预检 A、写 B」这种错位不可能发生;
|
||||
//! 3. **在等回程时把主按钮并掉**(`Form::busy`):连点两次「确认」不会写两条实例记录。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::{AdoptCommand, AdoptRequestView, AppCommand};
|
||||
|
||||
use crate::app::adopt::{AdoptMessage, Busy};
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 向导的一条消息。
|
||||
pub(super) fn on_adopt(&mut self, message: AdoptMessage) -> Task<Message> {
|
||||
match message {
|
||||
AdoptMessage::Open => {
|
||||
self.adopt_form.open();
|
||||
self.dispatch_adopt(AdoptCommand::Scan);
|
||||
}
|
||||
AdoptMessage::Close => {
|
||||
self.adopt_form.close();
|
||||
self.dispatch_adopt(AdoptCommand::Dismiss);
|
||||
}
|
||||
AdoptMessage::Select(version_id) => {
|
||||
self.adopt_form.selected = Some(version_id);
|
||||
}
|
||||
AdoptMessage::Name(name) => self.adopt_form.name = name,
|
||||
AdoptMessage::Target(target) => self.adopt_form.target = target,
|
||||
AdoptMessage::Check => {
|
||||
if let Some(request) = self.adopt_request() {
|
||||
self.adopt_form.busy = Busy::Checking;
|
||||
self.dispatch_adopt(AdoptCommand::Preview(Box::new(request)));
|
||||
}
|
||||
}
|
||||
AdoptMessage::Confirm => {
|
||||
if let Some(request) = self.adopt_request() {
|
||||
self.adopt_form.busy = Busy::Importing;
|
||||
self.dispatch_adopt(AdoptCommand::Confirm(Box::new(request)));
|
||||
}
|
||||
}
|
||||
AdoptMessage::Back => {
|
||||
// 退回候选列表 = 重新扫一次:`scan` 会把上一轮的预检/失败一起作废,
|
||||
// 于是界面回到 Pick 段,不需要再给 core 加一个「清失败」的命令。
|
||||
self.adopt_form.busy = Busy::Scanning;
|
||||
self.dispatch_adopt(AdoptCommand::Scan);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 回程到了:这一条命令已经交卷(`pump` 收到 `CoreEvent::Adopt` 时调,见 `update/clock.rs`)。
|
||||
pub(super) fn settle_adopt(&mut self) {
|
||||
self.adopt_form.settled();
|
||||
}
|
||||
|
||||
/// 当前表单能拼出的请求(没选版本就没有请求——按钮在那边也是禁用的)。
|
||||
fn adopt_request(&self) -> Option<AdoptRequestView> {
|
||||
let version_id = self.adopt_form.selected.clone()?;
|
||||
let name = self.adopt_form.name.trim();
|
||||
Some(AdoptRequestView {
|
||||
version_id,
|
||||
name: (!name.is_empty()).then(|| name.to_string()),
|
||||
target: self.adopt_form.target,
|
||||
})
|
||||
}
|
||||
|
||||
fn dispatch_adopt(&mut self, command: AdoptCommand) {
|
||||
self.dispatch(AppCommand::Adopt(Box::new(command)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{AdoptEvent, AdoptTargetView};
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::adopt::{AdoptMessage, Busy};
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn headless() -> App {
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 打开向导 = 表单进入「在等扫描」+ 派一条 `Scan`。
|
||||
///
|
||||
/// 抓得住:`Open` 没派命令(面板永远停在「正在扫描」)、或忘了置 `Busy::Scanning`(没有等待态)。
|
||||
/// 抓不住:扫描真的返回了什么(那在 presenter 的 `host/adopt` 用例里)。
|
||||
#[test]
|
||||
fn opening_the_wizard_asks_for_a_scan() {
|
||||
let mut app = headless();
|
||||
let _ = app.on_adopt(AdoptMessage::Open);
|
||||
assert!(app.adopt_form.open);
|
||||
assert_eq!(app.adopt_form.busy, Busy::Scanning);
|
||||
assert_eq!(
|
||||
app.adopt_form.target,
|
||||
AdoptTargetView::InstanceDir,
|
||||
"缺省不是 isolated"
|
||||
);
|
||||
}
|
||||
|
||||
/// 没选版本时「预检 / 确认」不派命令(否则会发出一条 `version_id` 为空的请求)。
|
||||
///
|
||||
/// 抓得住:`adopt_request()` 在没有选中项时仍拼出请求(空版本 id 打到 core 换一句
|
||||
/// `adopt_version_not_found`,用户看到莫名其妙的失败)。
|
||||
#[test]
|
||||
fn no_request_without_a_selected_version() {
|
||||
let mut app = headless();
|
||||
let _ = app.on_adopt(AdoptMessage::Open);
|
||||
let _ = app.on_adopt(AdoptMessage::Check);
|
||||
assert_eq!(app.adopt_form.busy, Busy::Scanning, "Check 不该改等待态");
|
||||
let _ = app.on_adopt(AdoptMessage::Confirm);
|
||||
assert_eq!(app.adopt_form.busy, Busy::Scanning);
|
||||
}
|
||||
|
||||
/// 选中 + 预检 = 一条 Preview 命令,且请求里的名字是**去过空白**的用户输入。
|
||||
#[test]
|
||||
fn check_sends_the_form_as_a_preview_request() {
|
||||
let mut app = headless();
|
||||
let _ = app.on_adopt(AdoptMessage::Open);
|
||||
let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string()));
|
||||
let _ = app.on_adopt(AdoptMessage::Name(" 生存 ".to_string()));
|
||||
let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared));
|
||||
let _ = app.on_adopt(AdoptMessage::Check);
|
||||
assert_eq!(app.adopt_form.busy, Busy::Checking);
|
||||
// 请求本体不在 App 上留副本:能证明意图的只有等待态与目标档
|
||||
assert_eq!(app.adopt_form.target, AdoptTargetView::Shared);
|
||||
}
|
||||
|
||||
/// 回程到了就解除等待(`settle_adopt` 由 `pump` 调)。
|
||||
#[test]
|
||||
fn an_incoming_event_settles_the_wait() {
|
||||
let mut app = headless();
|
||||
let _ = app.on_adopt(AdoptMessage::Open);
|
||||
assert!(app.adopt_form.busy.waiting());
|
||||
app.settle_adopt();
|
||||
assert_eq!(app.adopt_form.busy, Busy::Idle);
|
||||
// 事件真相进了 presenter 的状态(界面从它取词)
|
||||
app.state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::Adopt(Box::new(AdoptEvent::Cleared)),
|
||||
));
|
||||
assert!(app.state.adopt.scan.is_none());
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,16 @@ impl App {
|
||||
) {
|
||||
clean_scan = true;
|
||||
}
|
||||
// M223:导入向导「在等回程」只认事件到达(派发时置位、这里解除),
|
||||
// 于是连点「确认」不会写两条实例记录,界面也不会一直转圈。
|
||||
let settled_adopt = matches!(
|
||||
&event,
|
||||
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Adopt(_))
|
||||
);
|
||||
self.state.apply(event);
|
||||
if settled_adopt {
|
||||
self.settle_adopt();
|
||||
}
|
||||
}
|
||||
self.view_dirty = true;
|
||||
self.auto_select();
|
||||
|
||||
@@ -109,6 +109,11 @@ impl App {
|
||||
}
|
||||
};
|
||||
}
|
||||
// M223:实例页页头「导入」——以前是占位动作(点了只打一行日志),现在是
|
||||
// 「导入已有版本」向导的入口(M221 的 core 能力 + M223 的 presenter/GUI 接线)。
|
||||
pages::Message::Action(pages::Action::Import) => {
|
||||
return self.on_adopt(crate::app::adopt::AdoptMessage::Open);
|
||||
}
|
||||
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
|
||||
pages::Message::Action(action) => {
|
||||
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
|
||||
|
||||
@@ -185,10 +185,19 @@ impl App {
|
||||
),
|
||||
};
|
||||
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
|
||||
match self.dialog_model.as_ref() {
|
||||
let tree = match self.dialog_model.as_ref() {
|
||||
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
|
||||
None => tree,
|
||||
}
|
||||
};
|
||||
// M223:导入已有版本向导(实例页页头「导入」打开;没打开时原样返回页面树)。
|
||||
// 叠在模态**之上**:向导自己就是一层交互,模态只在崩溃/确认时出现,两者不会互相抢焦点。
|
||||
super::adopt::layer(
|
||||
&self.ui,
|
||||
tree,
|
||||
&self.adopt_form,
|
||||
&self.state.adopt,
|
||||
self.lang(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,33 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"),
|
||||
("InstancesLaunchSave", "Save", "保存"),
|
||||
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"),
|
||||
("AdoptTitle", "Import an existing version", "导入已有版本"),
|
||||
("AdoptSubtitle", "Register versions another launcher already installed. Nothing is copied: versions, libraries and assets stay where they are.", "把别的启动器已经装好的版本登记成实例。不复制任何东西:versions / libraries / assets 原地不动。"),
|
||||
("AdoptScanning", "Scanning versions/…", "正在扫描 versions/…"),
|
||||
("AdoptEmpty", "No version found under {dir}. Install one here, or point the game folder at the .minecraft another launcher uses.", "在 {dir} 下一个版本都没找到。可以用本启动器装一个,或者把游戏目录指向别的启动器用的那个 .minecraft。"),
|
||||
("AdoptSelectLabel", "Pick a version", "选择版本"),
|
||||
("AdoptReady", "Ready", "可导入"),
|
||||
("AdoptBlocked", "{count} problem(s)", "{count} 个问题"),
|
||||
("AdoptRegistered", "Already used by {name}", "已被 {name} 使用"),
|
||||
("AdoptTargetLabel", "Where should the game run?", "游戏在哪个目录里跑"),
|
||||
("AdoptTargetInstanceDir", "New folder in ULL data (default)", "ULL 数据区新建目录(缺省)"),
|
||||
("AdoptTargetInstanceDirHint", "mods, saves and config stay out of the other launcher's .minecraft", "mods / saves / config 不会落进别人的 .minecraft"),
|
||||
("AdoptTargetShared", "Share the game folder", "共用游戏目录"),
|
||||
("AdoptTargetSharedHint", "mods, saves and config are written into the other launcher's .minecraft", "mods / saves / config 会写进别人的 .minecraft"),
|
||||
("AdoptTargetVersionDir", "Use the version folder", "用版本目录"),
|
||||
("AdoptTargetVersionDirHint", "player data grows inside versions/<id>", "玩家数据会长在 versions/<id> 里"),
|
||||
("AdoptNameLabel", "Instance name (empty = version id)", "实例名(留空 = 版本 id)"),
|
||||
("AdoptPreview", "Check", "预检"),
|
||||
("AdoptConfirm", "Import", "导入"),
|
||||
("AdoptBack", "Back", "返回"),
|
||||
("AdoptClose", "Close", "关闭"),
|
||||
("AdoptPlanTitle", "Ready to import", "可以导入"),
|
||||
("AdoptPlanRunDir", "Game folder", "运行目录"),
|
||||
("AdoptPlanIsolation", "Isolation", "隔离方式"),
|
||||
("AdoptFailureTitle", "Import failed", "导入失败"),
|
||||
("AdoptProblemCount", "Every reason is listed below - nothing is collapsed into one sentence.", "下面逐条列出全部原因,不折叠成一句「导入失败」。"),
|
||||
("AdoptDoneTitle", "Imported", "已导入"),
|
||||
("AdoptDoneHint", "It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
|
||||
];
|
||||
|
||||
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
||||
|
||||
@@ -102,6 +102,35 @@ macro_rules! instances_entries {
|
||||
InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"),
|
||||
InstancesLaunchSave => ("Save", "保存"),
|
||||
InstancesLaunchReset => ("Reset to global", "恢复跟随全局"),
|
||||
|
||||
// ── 导入已有版本(M223):向导的全部文案
|
||||
AdoptTitle => ("Import an existing version", "导入已有版本"),
|
||||
AdoptSubtitle => ("Register versions another launcher already installed. Nothing is copied: versions, libraries and assets stay where they are.", "把别的启动器已经装好的版本登记成实例。不复制任何东西:versions / libraries / assets 原地不动。"),
|
||||
AdoptScanning => ("Scanning versions/…", "正在扫描 versions/…"),
|
||||
AdoptEmpty => ("No version found under {dir}. Install one here, or point the game folder at the .minecraft another launcher uses.", "在 {dir} 下一个版本都没找到。可以用本启动器装一个,或者把游戏目录指向别的启动器用的那个 .minecraft。"),
|
||||
AdoptSelectLabel => ("Pick a version", "选择版本"),
|
||||
AdoptReady => ("Ready", "可导入"),
|
||||
AdoptBlocked => ("{count} problem(s)", "{count} 个问题"),
|
||||
AdoptRegistered => ("Already used by {name}", "已被 {name} 使用"),
|
||||
AdoptTargetLabel => ("Where should the game run?", "游戏在哪个目录里跑"),
|
||||
AdoptTargetInstanceDir => ("New folder in ULL data (default)", "ULL 数据区新建目录(缺省)"),
|
||||
AdoptTargetInstanceDirHint => ("mods, saves and config stay out of the other launcher's .minecraft", "mods / saves / config 不会落进别人的 .minecraft"),
|
||||
AdoptTargetShared => ("Share the game folder", "共用游戏目录"),
|
||||
AdoptTargetSharedHint => ("mods, saves and config are written into the other launcher's .minecraft", "mods / saves / config 会写进别人的 .minecraft"),
|
||||
AdoptTargetVersionDir => ("Use the version folder", "用版本目录"),
|
||||
AdoptTargetVersionDirHint => ("player data grows inside versions/<id>", "玩家数据会长在 versions/<id> 里"),
|
||||
AdoptNameLabel => ("Instance name (empty = version id)", "实例名(留空 = 版本 id)"),
|
||||
AdoptPreview => ("Check", "预检"),
|
||||
AdoptConfirm => ("Import", "导入"),
|
||||
AdoptBack => ("Back", "返回"),
|
||||
AdoptClose => ("Close", "关闭"),
|
||||
AdoptPlanTitle => ("Ready to import", "可以导入"),
|
||||
AdoptPlanRunDir => ("Game folder", "运行目录"),
|
||||
AdoptPlanIsolation => ("Isolation", "隔离方式"),
|
||||
AdoptFailureTitle => ("Import failed", "导入失败"),
|
||||
AdoptProblemCount => ("Every reason is listed below - nothing is collapsed into one sentence.", "下面逐条列出全部原因,不折叠成一句「导入失败」。"),
|
||||
AdoptDoneTitle => ("Imported", "已导入"),
|
||||
AdoptDoneHint => ("It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user