From 41caae35711bb1217d69ef25506d11861b68ef8b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 14:00:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-iced):=20=E5=AE=9E=E4=BE=8B=E9=A1=B5?= =?UTF-8?q?=E3=80=8C=E5=AF=BC=E5=85=A5=E5=B7=B2=E6=9C=89=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E3=80=8D=E5=90=91=E5=AF=BC=EF=BC=88M223=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页头「导入」按钮(以前是占位动作,点了只打日志)现在打开四段向导: | 段 | 画什么 | |---|---| | Pick | 候选版本(可导入的才可点)+ 目标三档 chip + 实例名(缺省 InstanceDir) | | Preview | 生效运行目录(回程给的)+ 隔离档 + target_notes() 原文逐条 | | Failed | 稳定码 + problems 逐条(不折叠成一句「导入失败」) | | Done | 实例名 + 运行目录 + 提示原文 | - 向导是叠在页面之上的一层(遮罩 + 居中卡),不搅乱 list-detail 的版式判据; - 表单态在 App(app/adopt.rs 的 Form),事件真相在 presenter 的 AdoptState; - 在等回程时主按钮禁用(连点「确认」不会写两条实例); - i18n 中英目录 + 金表补 27 条向导文案(实例域末尾追加)。 --- crates/app-iced/src/app/adopt.rs | 349 ++++++++++++++++++ crates/app-iced/src/app/adopt/panes.rs | 131 +++++++ crates/app-iced/src/app/adopt/pick.rs | 223 +++++++++++ crates/app-iced/src/app/adopt/tests.rs | 201 ++++++++++ crates/app-iced/src/app/adopt/text.rs | 88 +++++ crates/app-iced/src/app/message.rs | 5 + crates/app-iced/src/app/mod.rs | 1 + crates/app-iced/src/app/state.rs | 5 + crates/app-iced/src/app/state/construct.rs | 1 + crates/app-iced/src/app/update.rs | 4 + crates/app-iced/src/app/update/adopt.rs | 176 +++++++++ crates/app-iced/src/app/update/clock.rs | 9 + .../app-iced/src/app/update/page_messages.rs | 5 + crates/app-iced/src/app/view.rs | 13 +- crates/i18n/src/catalog/golden.rs | 27 ++ crates/i18n/src/catalog/instances.rs | 29 ++ 16 files changed, 1265 insertions(+), 2 deletions(-) create mode 100644 crates/app-iced/src/app/adopt.rs create mode 100644 crates/app-iced/src/app/adopt/panes.rs create mode 100644 crates/app-iced/src/app/adopt/pick.rs create mode 100644 crates/app-iced/src/app/adopt/tests.rs create mode 100644 crates/app-iced/src/app/adopt/text.rs create mode 100644 crates/app-iced/src/app/update/adopt.rs diff --git a/crates/app-iced/src/app/adopt.rs b/crates/app-iced/src/app/adopt.rs new file mode 100644 index 0000000..94cba79 --- /dev/null +++ b/crates/app-iced/src/app/adopt.rs @@ -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, + /// 展示名(空 = 用版本 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() +} diff --git a/crates/app-iced/src/app/adopt/panes.rs b/crates/app-iced/src/app/adopt/panes.rs new file mode 100644 index 0000000..2f31048 --- /dev/null +++ b/crates/app-iced/src/app/adopt/panes.rs @@ -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() +} diff --git a/crates/app-iced/src/app/adopt/pick.rs b/crates/app-iced/src/app/adopt/pick.rs new file mode 100644 index 0000000..3a5cace --- /dev/null +++ b/crates/app-iced/src/app/adopt/pick.rs @@ -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::>(), + ) + .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::>(); + + 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) +} diff --git a/crates/app-iced/src/app/adopt/tests.rs b/crates/app-iced/src/app/adopt/tests.rs new file mode 100644 index 0000000..87d2a6b --- /dev/null +++ b/crates/app-iced/src/app/adopt/tests.rs @@ -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) -> 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}"); +} diff --git a/crates/app-iced/src/app/adopt/text.rs b/crates/app-iced/src/app/adopt/text.rs new file mode 100644 index 0000000..04e6e23 --- /dev/null +++ b/crates/app-iced/src/app/adopt/text.rs @@ -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), + } +} diff --git a/crates/app-iced/src/app/message.rs b/crates/app-iced/src/app/message.rs index 2d9c4e6..f9ec644 100644 --- a/crates/app-iced/src/app/message.rs +++ b/crates/app-iced/src/app/message.rs @@ -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`])。 diff --git a/crates/app-iced/src/app/mod.rs b/crates/app-iced/src/app/mod.rs index 4e2a686..4f428d9 100644 --- a/crates/app-iced/src/app/mod.rs +++ b/crates/app-iced/src/app/mod.rs @@ -27,6 +27,7 @@ //! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components` //! (组件源码里的文档与测试都按这个路径写)。 +mod adopt; mod appearance; mod backend; mod boot; diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index c575516..649aeb0 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -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,弹窗与「这次失败的现场」 diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 1b4ba1a..64a2148 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -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, diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 80cdd9f..ca9d02d 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -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 { diff --git a/crates/app-iced/src/app/update/adopt.rs b/crates/app-iced/src/app/update/adopt.rs new file mode 100644 index 0000000..c5aee94 --- /dev/null +++ b/crates/app-iced/src/app/update/adopt.rs @@ -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 { + 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 { + 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()); + } +} diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index 184c82b..90165f1 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -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(); diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index 992de11..da352e9 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -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)"); diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index dfa0abe..44104e5 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -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(), + ) } } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 3c439c1..7c8b3c8 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -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/", "玩家数据会长在 versions/ 里"), + ("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`)。 diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 3fbd0f9..94ef6a5 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -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/", "玩家数据会长在 versions/ 里"), + 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.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"), } } }; }