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:
AskaEth committed 2026-11-10 19:37:42 +08:00
1 parent 5a59cb33b2
commit 41caae3571
16 files changed
+1265 -2

No files matched your search

+349
View File
@@ -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()
}
+131
View File
@@ -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()
}
+223
View File
@@ -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)
}
+201
View File
@@ -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}");
}
+88
View File
@@ -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),
}
}
+5
View File
@@ -69,6 +69,11 @@ pub enum Message {
Demo(components::gallery::DemoMessage), Demo(components::gallery::DemoMessage),
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。 /// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
Dialog(super::dialog::Message), Dialog(super::dialog::Message),
/// 导入已有版本向导(M223):打开 / 选版本 / 选目标 / 预检 / 确认 / 关闭。
///
/// 落点是 `app/update/adopt.rs`。页面只发一条入口意图
/// (`pages::Message::Action(pages::Action::Import)`),向导自己的四段状态都在这里收口。
Adopt(super::adopt::AdoptMessage),
} }
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。 /// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
+1
View File
@@ -27,6 +27,7 @@
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components` //! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
//! (组件源码里的文档与测试都按这个路径写)。 //! (组件源码里的文档与测试都按这个路径写)。
mod adopt;
mod appearance; mod appearance;
mod backend; mod backend;
mod boot; mod boot;
+5
View File
@@ -196,6 +196,11 @@ pub struct App {
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在 /// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。 /// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
pub(super) launch_form: pages::LaunchForm, pub(super) launch_form: pages::LaunchForm,
/// 实例页「导入已有版本」向导的表单态(M223):选中版本 / 展示名 / 目标档 / 在等回程。
///
/// 住在 `App` 的理由与 `launch_form` 一致:向导跨帧(扫描 → 选 → 预检 → 确认),
/// 而候选/预检/失败/完成这些**事件真相**在 `AppState.adopt`(presenter),不在这里。
pub(super) adopt_form: super::adopt::Form,
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。 /// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
/// ///
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」 /// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
@@ -136,6 +136,7 @@ impl App {
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(), download_draft: pages::InstallDraft::default(),
launch_form: pages::LaunchForm::default(), launch_form: pages::LaunchForm::default(),
adopt_form: super::super::adopt::Form::default(),
install_cancelled_seen: 0, install_cancelled_seen: 0,
launch_ends_seen: 0, launch_ends_seen: 0,
dialog_card: None, dialog_card: None,
+4
View File
@@ -46,6 +46,8 @@ use super::state::{App, IDLE_TICK, TICK, WATCH_WINDOW};
use super::CAPTURE_AT_FRAME; use super::CAPTURE_AT_FRAME;
mod account; mod account;
// M223:导入已有版本向导(扫描 / 预检 / 确认 / 关闭 / 退回)
mod adopt;
mod agent; mod agent;
mod appearance; mod appearance;
pub(super) mod boot_env; pub(super) mod boot_env;
@@ -149,6 +151,8 @@ impl App {
} }
Message::Page(message) => self.on_page(message), Message::Page(message) => self.on_page(message),
Message::Dialog(message) => dialog::on_dialog(self, message), Message::Dialog(message) => dialog::on_dialog(self, message),
// M223:导入已有版本向导(打开 / 选版本 / 选目标 / 预检 / 确认 / 退回 / 关闭)
Message::Adopt(message) => self.on_adopt(message),
Message::Agent(message) => { Message::Agent(message) => {
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。 // `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
if let crate::shell::agent::Message::Key(event) = &message { if let crate::shell::agent::Message::Key(event) = &message {
+176
View File
@@ -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());
}
}
+9
View File
@@ -140,7 +140,16 @@ impl App {
) { ) {
clean_scan = true; clean_scan = true;
} }
// M223:导入向导「在等回程」只认事件到达(派发时置位、这里解除),
// 于是连点「确认」不会写两条实例记录,界面也不会一直转圈。
let settled_adopt = matches!(
&event,
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Adopt(_))
);
self.state.apply(event); self.state.apply(event);
if settled_adopt {
self.settle_adopt();
}
} }
self.view_dirty = true; self.view_dirty = true;
self.auto_select(); 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 模块文档的偏离登记)
pages::Message::Action(action) => { pages::Message::Action(action) => {
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
+11 -2
View File
@@ -185,10 +185,19 @@ impl App {
), ),
}; };
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。 // 队列里有一条就叠一层模态(没有则原样返回)。模型由 `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), Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
None => tree, None => tree,
} };
// M223:导入已有版本向导(实例页页头「导入」打开;没打开时原样返回页面树)。
// 叠在模态**之上**:向导自己就是一层交互,模态只在崩溃/确认时出现,两者不会互相抢焦点。
super::adopt::layer(
&self.ui,
tree,
&self.adopt_form,
&self.state.adopt,
self.lang(),
)
} }
} }
+27
View File
@@ -59,6 +59,33 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"), ("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"),
("InstancesLaunchSave", "Save", "保存"), ("InstancesLaunchSave", "Save", "保存"),
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"), ("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`)。 /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
+29
View File
@@ -102,6 +102,35 @@ macro_rules! instances_entries {
InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"), InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"),
InstancesLaunchSave => ("Save", "保存"), InstancesLaunchSave => ("Save", "保存"),
InstancesLaunchReset => ("Reset to global", "恢复跟随全局"), 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.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
} } } }
}; };
} }