diff --git a/crates/app-iced/src/app/adopt.rs b/crates/app-iced/src/app/adopt.rs index d64ced2..bb73656 100644 --- a/crates/app-iced/src/app/adopt.rs +++ b/crates/app-iced/src/app/adopt.rs @@ -9,7 +9,13 @@ //! | Failed | 稳定码 + 人读原因 + **`problems[]` 逐条** | `AdoptEvent::Failed` | [`panes`] | //! | Done | 实例名 / 运行目录 / 提示原文 | `AdoptEvent::Done` | [`panes`] | //! -//! 「现在画哪一段」只有一处判据(presenter 的 `AdoptState::stage`),本文件只做投影。 +//! 「现在画哪一段」的判据只有一处:presenter 的 `AdoptState::stage`,外面套一条收窄 +//! [`Form::stage`]——`Preview` 只在用户**点过预检**(`Form::checked`)之后才算数。收窄的理由: +//! 共享档 / 版本目录档一被点选就要显示后果原文(§5.2),那要用一次**零写盘**的预检换; +//! 但那次结果不该把用户直接甩到确认页(实例名还没填完)。 +//! +//! 卡壳与页脚在 [`shell`](定高可滚 / 收高两种几何),三段视图在 [`pick`] 与 [`panes`], +//! 等宽与正文小工具在 `text`,D30 出图种子在 `shot`(`ULL_ADOPT_STATE`,只在 `--screenshot` 下生效)。 //! //! ## 三条纪律(契约 §5 的 1/2/3) //! @@ -34,40 +40,33 @@ mod panes; mod pick; +mod shell; +/// D30 出图用的状态夹具(只在 `--screenshot` 下按环境变量生效)。 +pub(crate) mod shot; mod text; #[cfg(test)] mod tests; -use iced::widget::{column, container, mouse_area, opaque, row, Space}; +use iced::widget::{container, mouse_area, opaque, 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_i18n::{Catalog, Lang}; use unlucky_presenter::dto::{ AdoptCandidateView, AdoptPlanView, 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; +const CARD_MAX_HEIGHT: f32 = 620.0; /// 正在等哪一条命令的回程。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -222,7 +221,7 @@ pub(super) fn layer<'a>( .on_press(adopt(AdoptMessage::Close)); // 透明的一层只负责居中;吃点击的 `opaque` 只包卡片(点卡内不关,点遮罩才关)。 - let centered = container(opaque(card(ui, form, adopt_state, catalog))) + let centered = container(opaque(shell::card(ui, form, adopt_state, catalog))) .width(Length::Fill) .height(Length::Fill) .align_x(Alignment::Center) @@ -232,84 +231,8 @@ pub(super) fn layer<'a>( 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 stage = form.stage(adopt_state); - let body = column![ - frame::text_role( - ui, - TypeRole::HeadlineSmall, - true, - title_text(stage, catalog), - ui.color(Role::OnSurface), - ) - .map(Message::Page), - wrapped( - ui, - subtitle(stage, adopt_state, catalog), - ui.color(Role::OnSurfaceVariant) - ), - frame::divider(ui).map(Message::Page), - middle(ui, form, adopt_state, stage, catalog), - frame::divider(ui).map(Message::Page), - footer(ui, form, adopt_state, stage, 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(stage: AdoptStage, catalog: Catalog) -> &'static str { - match 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(stage: AdoptStage, adopt_state: &AdoptState, catalog: Catalog) -> String { - match 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>( +pub(super) fn middle<'a>( ui: &'a Ui, form: &'a Form, adopt_state: &'a AdoptState, @@ -329,62 +252,3 @@ fn middle<'a>( AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog), } } - -/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。 -fn footer<'a>( - ui: &'a Ui, - form: &'a Form, - adopt_state: &'a AdoptState, - stage: AdoptStage, - catalog: Catalog, -) -> Element<'a, Message> { - 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 index 2f31048..e3675f4 100644 --- a/crates/app-iced/src/app/adopt/panes.rs +++ b/crates/app-iced/src/app/adopt/panes.rs @@ -10,7 +10,7 @@ //! 缺省档会带 `-2`/`-3` 后缀(两个不同展示名可能落到同一个物理目录),**只有 core 知道** //! 最后用了哪个——页面对它再做一次派生就会与真正启动时用的目录分叉。 -use iced::widget::{column, scrollable}; +use iced::widget::column; use iced::{Element, Length}; use unlucky_design::color::Role; @@ -18,12 +18,10 @@ 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>( @@ -39,6 +37,7 @@ pub(super) fn preview<'a>( ); }; let mut body = column![ + field_line(ui, catalog.text(Key::AdoptInstanceName), &plan.name), field_line( ui, catalog.text(Key::AdoptPlanRunDir), @@ -93,10 +92,7 @@ pub(super) fn failed<'a>( 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() + body.into() } /// 完成:实例名 + 运行目录 + 提示原文。 @@ -109,7 +105,7 @@ pub(super) fn done<'a>( 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::AdoptInstanceName), &done.name), field_line( ui, catalog.text(Key::AdoptPlanRunDir), diff --git a/crates/app-iced/src/app/adopt/pick.rs b/crates/app-iced/src/app/adopt/pick.rs index 2ac06d1..a3467fa 100644 --- a/crates/app-iced/src/app/adopt/pick.rs +++ b/crates/app-iced/src/app/adopt/pick.rs @@ -6,7 +6,7 @@ //! 目标档三枚 chip 的**短提示**在选中时立刻换(共享/版本目录档用 `Error` 色); //! **后果原文**不在这里——它在 Preview 段逐条画 `plan.notes`(core `target_notes()` 的原文)。 -use iced::widget::{button, column, container, row, scrollable}; +use iced::widget::{button, column, container, row}; use iced::{Alignment, Background, Border, Color, Element, Length}; use unlucky_design::color::Role; @@ -23,7 +23,7 @@ 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}; +use super::{adopt, AdoptMessage, Form}; /// Pick 段整体。 pub(super) fn pick<'a>( @@ -89,10 +89,7 @@ fn candidate_list<'a>( .spacing(ui.space(Sp::X1)) .width(Length::Fill); - scrollable(rows) - .height(Length::Fixed(CARD_LIST_HEIGHT)) - .style(frame::scroll_style(ui)) - .into() + rows.into() } /// 一条候选。 diff --git a/crates/app-iced/src/app/adopt/shell.rs b/crates/app-iced/src/app/adopt/shell.rs new file mode 100644 index 0000000..1f504a5 --- /dev/null +++ b/crates/app-iced/src/app/adopt/shell.rs @@ -0,0 +1,187 @@ +//! 向导卡的外壳与页脚(M223 从 `app/adopt.rs` 搬出,守 400 行硬线)。 +//! +//! | 函数 | 职责 | +//! |---|---| +//! | [`card`] | 卡壳(标题 + 说明 + 中间区 + 页脚)、定高/收高两种几何、遮罩上的那一张卡 | +//! | [`title_text`] / [`subtitle`] | 四段的标题与一句说明(文案全部来自 i18n 目录) | +//! | [`footer`] | 按段给按钮组合;在等回程时主按钮禁用 | +//! +//! 纯搬移:函数体一行未改,只是换了文件(`app/adopt.rs` 落笔 412 行越过 `components::gate` +//! 的 400 行硬线)。调用方仍是 `app/adopt.rs` 的 `layer` 与 `middle`。 + +use iced::widget::{column, container, row, scrollable, Space}; +use iced::{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}; +use unlucky_presenter::dto::{AdoptStage, AdoptState}; + +use crate::app::components::buttons::{self, Button}; +use crate::pages::frame; +use crate::theme::Ui; + +use super::super::message::Message; +use super::text::{target_label, wrapped}; +use super::{adopt, middle, AdoptMessage, Form, CARD_MAX_HEIGHT, CARD_WIDTH}; + +/// 卡片本体:标题 + 一句说明 + 中间(按段) + 页脚按钮。 +pub(super) fn card<'a>( + ui: &'a Ui, + form: &'a Form, + adopt_state: &'a AdoptState, + catalog: Catalog, +) -> Element<'a, Message> { + let stage = form.stage(adopt_state); + let middle = middle(ui, form, adopt_state, stage, catalog); + let head = column![ + frame::text_role( + ui, + TypeRole::HeadlineSmall, + true, + title_text(stage, catalog), + ui.color(Role::OnSurface), + ) + .map(Message::Page), + wrapped( + ui, + subtitle(stage, adopt_state, catalog), + ui.color(Role::OnSurfaceVariant) + ), + frame::divider(ui).map(Message::Page), + ] + .spacing(ui.space(Sp::X3)) + .width(Length::Fill); + + let tail = column![ + frame::divider(ui).map(Message::Page), + footer(ui, form, adopt_state, stage, catalog), + ] + .spacing(ui.space(Sp::X3)) + .width(Length::Fill); + + // 列表段(Pick / Failed)**定高 + 中间区可滚**:内容再长,页脚按钮也不会被裁掉 + // (第一版就是被裁掉的)。短内容段(Preview / Done)按内容高收,不留一大片空白。 + let list_stage = matches!(stage, AdoptStage::Pick | AdoptStage::Failed); + let card = if list_stage { + let body = column![ + head, + scrollable(middle) + .height(Length::Fill) + .style(frame::scroll_style(ui)), + tail, + ] + .spacing(ui.space(Sp::X3)) + .width(Length::Fill); + container(body) + .width(Length::Fixed(CARD_WIDTH)) + .height(Length::Fixed(CARD_MAX_HEIGHT)) + } else { + let body = column![head, middle, tail] + .spacing(ui.space(Sp::X3)) + .width(Length::Fill); + container(body) + .width(Length::Fixed(CARD_WIDTH)) + .max_height(CARD_MAX_HEIGHT) + }; + + card.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() +} + +/// 段标题(四段各一句)。 +pub(super) fn title_text(stage: AdoptStage, catalog: Catalog) -> &'static str { + match 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 段说清「去实例列表启动」)。 +pub(super) fn subtitle(stage: AdoptStage, adopt_state: &AdoptState, catalog: Catalog) -> String { + match stage { + AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(), + // 预检:版本名 · 目标档(隔离档在正文里单独一行,重复一遍只会更吵) + AdoptStage::Preview => adopt_state + .plan + .as_ref() + .map(|plan| format!("{} · {}", plan.name, 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(), + } +} + +/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。 +pub(super) fn footer<'a>( + ui: &'a Ui, + form: &'a Form, + adopt_state: &'a AdoptState, + stage: AdoptStage, + catalog: Catalog, +) -> Element<'a, Message> { + 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::Pick | AdoptStage::Preview) { + 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/shot.rs b/crates/app-iced/src/app/adopt/shot.rs new file mode 100644 index 0000000..99df6bd --- /dev/null +++ b/crates/app-iced/src/app/adopt/shot.rs @@ -0,0 +1,199 @@ +//! 出图用的向导状态夹具(D30 视觉验收的输入)。 +//! +//! ## 为什么要有它 +//! +//! 向导是**四段有状态**的界面,而截图通道只跑一次 `view()`(第 3 帧落盘),没有真人点按钮。 +//! 组件库的做法是 `--screen=gallery` + `ForcedState`,页面层的做法是 `pages/instances/demo.rs` +//! 的 `DEMO`——同一类要求,同一类做法:**给渲染器一份确定的状态**,而不是让截图去赌异步时序。 +//! +//! ## 哪些是真、哪些是夹具(复审请照这条核对) +//! +//! | 内容 | 来源 | +//! |---|---| +//! | 候选 / 问题 / 失败原因 | **夹具**(合成的 id 与问题文本,只为把版面撑到真实形态) | +//! | 运行目录 / 隔离档 | 夹具(固定路径,便于两张图逐像素对照) | +//! | **后果原文** | **真**:`unlucky_core::game::adopt::target_notes()` 现算,一个字不抄 | +//! | 文案(标题/标签/按钮) | **真**:`unlucky_i18n` 目录 | +//! | 渲染 | **真**:`--software-renderer`(tiny-skia)1180×760,与产品路径同一棵控件树 | +//! +//! 也就是说:这些图证明**版面与文案**,不证明 core 的判据(判据的证据在 `host/adopt/tests.rs` +//! 与 `core/tests/adopt/`)。 +//! +//! ## 怎么用 +//! +//! ```text +//! ULL_ADOPT_STATE=pick|empty|preview|shared|failed|done \ +//! unlucky-launcher-iced.exe --software-renderer --screenshot [--screenshot-theme dark] +//! ``` +//! +//! 只在 `--screenshot` 已经给了的情况下生效(交互模式读这个环境变量没有意义,直接忽略)。 + +use std::path::PathBuf; + +use unlucky_core::game::adopt::{target_notes, AdoptTarget}; +use unlucky_presenter::dto::{ + AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView, + AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptState, AdoptTargetView, +}; + +use super::{Busy, Form}; + +/// 夹具里的版本目录(只是**画**出来的路径,不碰盘)。 +const VERSIONS_DIR: &str = "D:/minecraft/.minecraft/versions"; + +/// 夹具里的数据区运行目录。 +const INSTANCE_RUN_DIR: &str = "D:/ULL/.ULL/data/instances/Better-MC"; + +/// 夹具里的游戏根。 +const GAME_ROOT: &str = "D:/minecraft/.minecraft"; + +/// 环境变量名(见模块文档)。 +pub(crate) const ENV: &str = "ULL_ADOPT_STATE"; + +/// 按名字造一份「打开着的向导」;不认识的名字返回 `None`(不影响正常启动)。 +pub(crate) fn seed(name: &str) -> Option<(Form, AdoptState)> { + let name = name.trim().to_ascii_lowercase(); + let mut form = Form { + open: true, + ..Form::default() + }; + let mut state = AdoptState::default(); + + match name.as_str() { + // 扫描完成、还没选版本(列表里三态齐全:可导入 / 已登记 / 有问题) + "pick" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + form.busy = Busy::Idle; + } + // 游戏根下真没有版本(空态) + "empty" => { + state.apply(AdoptEvent::Scan(Box::new(AdoptScanView { + versions_dir: PathBuf::from(VERSIONS_DIR), + candidates: Vec::new(), + }))); + form.busy = Busy::Idle; + } + // 选中缺省档(InstanceDir)并点过预检 + "preview" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + state.apply(AdoptEvent::Planned(Box::new(plan_view( + AdoptTargetView::InstanceDir, + AdoptIsolationView::Custom, + INSTANCE_RUN_DIR, + )))); + form.selected = Some("1.20.1".to_string()); + form.checked = true; + form.busy = Busy::Idle; + } + // 选中共享档(Shared):后果原文由 core `target_notes()` 现算 + "shared" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + state.apply(AdoptEvent::Planned(Box::new(plan_view( + AdoptTargetView::Shared, + AdoptIsolationView::Shared, + GAME_ROOT, + )))); + form.selected = Some("1.20.1".to_string()); + form.target = AdoptTargetView::Shared; + form.checked = true; + form.busy = Busy::Idle; + } + // 预检失败:两条问题逐条列出(不许折叠成一句) + "failed" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView { + code: "adopt_not_adoptable".to_string(), + message: "版本 1.19.4 不能登记:清单不是合法 JSON".to_string(), + problems: vec![ + AdoptProblemView { + code: "manifest_invalid_json".to_string(), + message: format!( + "清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3" + ), + }, + AdoptProblemView { + code: "missing_parent".to_string(), + message: "版本 1.19.4 继承的父版本 1.19.3 不在盘上".to_string(), + }, + ], + }))); + form.selected = Some("1.19.4".to_string()); + form.checked = true; + form.busy = Busy::Idle; + } + // 登记完成 + "done" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + state.apply(AdoptEvent::Done(Box::new(AdoptDoneView { + instance_id: "2f1c0a5e-7f4b-4c1d-9a2e-6b8d5c3a1f70".to_string(), + name: "Better MC".to_string(), + version_id: "1.20.1-fabric-0.15.0".to_string(), + isolation: AdoptIsolationView::Custom, + run_dir: PathBuf::from(INSTANCE_RUN_DIR), + dir_created: true, + notes: target_notes(&AdoptTarget::InstanceDir, &PathBuf::from(INSTANCE_RUN_DIR)), + }))); + form.selected = Some("1.20.1-fabric-0.15.0".to_string()); + form.checked = true; + form.busy = Busy::Idle; + } + _ => return None, + } + Some((form, state)) +} + +/// 一份扫描结果(三态齐全)。 +fn scan_view() -> AdoptScanView { + AdoptScanView { + versions_dir: PathBuf::from(VERSIONS_DIR), + candidates: vec![ + AdoptCandidateView { + version_id: "1.20.1".to_string(), + jar_present: true, + inherits: Vec::new(), + registered: Vec::new(), + problems: Vec::new(), + }, + AdoptCandidateView { + version_id: "1.20.1-fabric-0.15.0".to_string(), + jar_present: false, + inherits: vec!["1.20.1-fabric-0.15.0".to_string(), "1.20.1".to_string()], + registered: vec!["Better MC".to_string()], + problems: Vec::new(), + }, + AdoptCandidateView { + version_id: "1.19.4".to_string(), + jar_present: true, + inherits: Vec::new(), + registered: Vec::new(), + problems: vec![AdoptProblemView { + code: "manifest_invalid_json".to_string(), + message: format!( + "清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3" + ), + }], + }, + ], + } +} + +/// 一份预检结果。**`notes` 由 core 现算**(共享档/版本目录档的后果原文只有一处)。 +fn plan_view( + target: AdoptTargetView, + isolation: AdoptIsolationView, + run_dir: &str, +) -> AdoptPlanView { + let core_target = match target { + AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir, + AdoptTargetView::Shared => AdoptTarget::Shared, + AdoptTargetView::VersionDir => AdoptTarget::VersionDir, + }; + AdoptPlanView { + version_id: "1.20.1".to_string(), + name: "1.20.1".to_string(), + target, + isolation, + run_dir: PathBuf::from(run_dir), + notes: target_notes(&core_target, &PathBuf::from(run_dir)), + } +} diff --git a/crates/app-iced/src/app/adopt/tests.rs b/crates/app-iced/src/app/adopt/tests.rs index 082ff7f..607c047 100644 --- a/crates/app-iced/src/app/adopt/tests.rs +++ b/crates/app-iced/src/app/adopt/tests.rs @@ -90,11 +90,11 @@ fn the_four_stages_project_from_the_event_state() { // Pick assert_eq!(state.stage(), AdoptStage::Pick); assert_eq!( - title_text(state.stage(), catalog), + shell::title_text(state.stage(), catalog), catalog.text(Key::AdoptTitle), "标题走目录" ); - assert!(subtitle(state.stage(), &state, catalog).contains("不复制")); + assert!(shell::subtitle(state.stage(), &state, catalog).contains("不复制")); // Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改 let note = @@ -136,7 +136,7 @@ fn the_four_stages_project_from_the_event_state() { "两条问题一条都不许吞" ); assert_eq!( - title_text(state.stage(), catalog), + shell::title_text(state.stage(), catalog), catalog.text(Key::AdoptFailureTitle) ); @@ -152,7 +152,7 @@ fn the_four_stages_project_from_the_event_state() { }))); assert_eq!(state.stage(), AdoptStage::Done); assert_eq!( - title_text(state.stage(), catalog), + shell::title_text(state.stage(), catalog), catalog.text(Key::AdoptDoneTitle) ); } diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 64a2148..1dc5802 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -158,6 +158,8 @@ impl App { // M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。 app.hydrate_ai_settings(); // M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。 + // M223: adopt wizard screenshot seed (evidence channel only; `ULL_ADOPT_STATE`). + app.hydrate_adopt_state(); app.hydrate_java_settings(); // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 diff --git a/crates/app-iced/src/app/update/adopt.rs b/crates/app-iced/src/app/update/adopt.rs index 597d1d0..984d9ba 100644 --- a/crates/app-iced/src/app/update/adopt.rs +++ b/crates/app-iced/src/app/update/adopt.rs @@ -85,6 +85,23 @@ impl App { } /// 回程到了:这一条命令已经交卷(`pump` 收到 `CoreEvent::Adopt` 时调,见 `update/clock.rs`)。 + /// 出图通道的种子(`ULL_ADOPT_STATE`,与 `ULL_JAVA_STATE` / `ULL_AI_STATE` 同一族)。 + /// + /// **只在 `--screenshot` 下生效**:交互模式读这个环境变量没有意义(也没有人会设它)。 + /// 不认识的名字一律忽略,退回正常启动路径(向导保持关闭)。 + pub(in crate::app) fn hydrate_adopt_state(&mut self) { + if self.options.screenshot.is_none() { + return; + } + let Ok(raw) = std::env::var(crate::app::adopt::shot::ENV) else { + return; + }; + if let Some((form, state)) = crate::app::adopt::shot::seed(&raw) { + self.adopt_form = form; + self.state.adopt = state; + } + } + pub(super) fn settle_adopt(&mut self) { self.adopt_form.settled(); } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 7c8b3c8..3da5ed8 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -83,7 +83,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("AdoptPlanRunDir", "Game folder", "运行目录"), ("AdoptPlanIsolation", "Isolation", "隔离方式"), ("AdoptFailureTitle", "Import failed", "导入失败"), - ("AdoptProblemCount", "Every reason is listed below - nothing is collapsed into one sentence.", "下面逐条列出全部原因,不折叠成一句「导入失败」。"), + ("AdoptProblemCount", "Every reason is listed below:", "全部原因如下:"), + ("AdoptInstanceName", "Instance name", "实例名"), ("AdoptDoneTitle", "Imported", "已导入"), ("AdoptDoneHint", "It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"), ]; diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 94ef6a5..2bd1aab 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -128,7 +128,8 @@ macro_rules! instances_entries { AdoptPlanRunDir => ("Game folder", "运行目录"), AdoptPlanIsolation => ("Isolation", "隔离方式"), AdoptFailureTitle => ("Import failed", "导入失败"), - AdoptProblemCount => ("Every reason is listed below - nothing is collapsed into one sentence.", "下面逐条列出全部原因,不折叠成一句「导入失败」。"), + AdoptProblemCount => ("Every reason is listed below:", "全部原因如下:"), + AdoptInstanceName => ("Instance name", "实例名"), AdoptDoneTitle => ("Imported", "已导入"), AdoptDoneHint => ("It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"), } }