feat(dlflow): M105 安装选项区(四家加载器 + 官方版本列表 + 实例名)与取消安装真停

用户实跑反馈两条,本轮各自定位到根因并修掉:

1. 「下载页没有前置加载器选项」= 功能未做:`Message::InstallVersion` 直接派
   `AppCommand::InstallVersion`(= 只装原版),core 早在 M32/M35/M45 就有四家加载器的
   安装管线。现在选中版本之后面板先给「安装选项」区(原版 / Forge / NeoForge / Fabric /
   Quilt + 该加载器的官方版本列表 + 实例名),点「安装」才派
   `AppCommand::CreateInstance`。
   - 加载器版本列表复用 core 的 `LoaderSource::loader_versions`,经新增的
     `AppCommand::ListLoaderVersions` / `CoreEvent::LoaderVersions` 回流;
     缓存键是 `(加载器, 原版版本)`,切版本不会串列表;取不到给具名原因并**禁掉确认**。
   - 不兼容组合用静态规则在**选中那一刻**说清(NeoForge 需 1.20.1+、Fabric/Quilt 需
     1.14+、远古版四家都不支持),不让用户「选完才失败」,也不为不可能的组合作无谓联网。
   - 选项草稿(选中 + 加载器 + 加载器版本 + 实例名)住在 `App`,`refresh_view` 带进页面
     模型(与分类筛选同一做法;页面自己存不住跨事件的东西)。

2. 「点取消没反应」= 真 bug,两跳各断一半:
   - 第一跳(UI 侧):取消确实生效了,但**还在跑的后台线程继续发进度**,`InstallProgress(None)`
     刚把面板收回,下一条「下载中」立刻又点亮它 —— 用户看到的就是「点了没反应」。
     修法:`CoreHost` 加安装代次 `install_epoch`(开工与取消都 +1),`HostTask::Install` /
     `InstallDone` 带代次回来,代次不符一律丢。
   - 第二跳(core 侧):`execute` 没有任何取消入参,取消只停在段边界,传输根本没停。
     新增 `execute_with_cancel(…, cancel: Option<&AtomicBool>)`(既有 `execute` 原样包装,
     CLI 一字未改):取消后不再起新文件,在飞的那个与「取消 future」`select` 赛跑,取消赢就
     drop 掉下载 future(响应流关闭、`.ullpart` 由 `PartFile::Drop` 清掉),并且不写版本清单,
     返回新的 `InstallErrorKind::Cancelled`。
   - 另外:取消后**绝不登记实例**(文件段跑完但用户已取消的那一路也拦住);取消文案把
     「哪些留着、哪些一定没发生」说清(D21)。

实测(真网络装置 `host/cancel_tests.rs`,`#[ignore]` 守着,跑法见文件头):
取消前 files_done=2 / `.ullpart` 613 字节;取消后 20 s 进度事件 **0 条**、`.ullpart` **0 字节**
(-613)、版本清单未落盘、实例目录不存在。取消前是 +201466 字节(同一个装置量的)。

文案全部进 i18n(中英,9 条新 key + 金表同步)。
This commit is contained in:
AskaEth committed 2026-11-04 22:12:22 +08:00
1 parent d690d09b84
commit 30b1ff6f48
39 files changed
+2550 -518

No files matched your search

@@ -0,0 +1,213 @@
//! 选项区的控件树(M105)。
//!
//! ## 版面(424 正文列里的四段)
//!
//! ```text
//! 1.21.9 [安装]
//! 安装选项
//! [原版][Forge][NeoForge]
//! [Fabric][Quilt]
//! 加载器版本 ← 只在选了加载器时出现
//! ┌ 0.16.5 ┐ ← 官方那份列表(menu + 滚动,最多看两行)
//! │ 0.16.4 │
//! └───────────────────┘
//! [实例名 ]
//! NeoForge 需要 Minecraft 1.20.1 或更新 ← 不兼容时(Error 色)
//! ```
//!
//! ## 为什么加载器分成两行
//!
//! 五家一起排在 424 宽里最宽的那组(`原版 + Forge + NeoForge + Fabric + Quilt`)实测会溢出,
//! 而 chip 的几何是组件自己的真源(`ChipGeometry`),页面不许压它 ⇒ 折成两行。
//! 顺序仍是任务书的口径(原版 / Forge / NeoForge / Fabric / Quilt),只是换行。
//!
//! ## 为什么「不兼容」不禁用 chip、而是禁用确认键
//!
//! 禁用的 chip 点不动,用户就**看不到原因**(那正是「选完才失败」的另一种形态)。
//! 现在的做法:五家永远都能点,选中不兼容的那家时下面出现一行具名原因,同时「安装」变成禁用态。
//! 于是「为什么不能装」和「怎么才能装」永远同屏。
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{menu, Menu, MenuItem};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::model::{Loader, LoaderVersionsView, OptionsView};
/// 五家加载器(一行放得下:实测五枚 chip 合计约 311 dp,正文列 424)。
const LOADERS: [Loader; 5] = [
Loader::Vanilla,
Loader::Forge,
Loader::NeoForge,
Loader::Fabric,
Loader::Quilt,
];
/// 菜单项高度 = 48(`MenuGeometry` 的项高)×2 行 + 上下留白 8×2 = 112。
/// 这么算是为了避免「列表被截掉半行」——滚动区高度必须覆盖整行。
const VERSION_ROWS: f32 = 7.0;
/// 选项区(面板的下半块)。
pub(crate) fn section<'a>(
ui: &'a Ui,
catalog: Catalog,
options: &'a OptionsView,
) -> Element<'a, Message> {
let head = row![
container(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
options.version.as_str(),
ui.color(Role::OnSurface),
))
.width(Length::Fill),
confirm(ui, catalog, options),
]
.width(Length::Fill)
.align_y(Alignment::Center);
let mut body = column![
head,
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::DownloadOptionsTitle),
ui.color(Role::OnSurfaceVariant),
),
loader_row(ui, catalog, options),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、
// 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。
if show_versions(options) {
body = body.push(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::DownloadOptionsLoaderVersion),
ui.color(Role::OnSurfaceVariant),
));
body = body.push(version_list(ui, catalog, options));
}
body = body.push(text_field(
ui,
Field::new(options.instance_name.as_str())
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::DownloadOptionsInstanceName))
.placeholder(catalog.text(Key::DownloadOptionsInstanceNameHint)),
Message::SetInstanceName,
));
if let Some(problem) = &options.problem {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
problem.as_str(),
ui.color(Role::Error),
));
}
body.into()
}
/// 加载器版本这一段要不要画(见 `section` 的注释)。
fn show_versions(options: &OptionsView) -> bool {
if options.loader == Loader::Vanilla || options.problem.is_some() {
return false;
}
match &options.versions {
LoaderVersionsView::Loading => true,
LoaderVersionsView::Ready(items) => !items.is_empty(),
LoaderVersionsView::Failed(_) => false,
}
}
/// 一行加载器 chip(Filter 档:选中 = `SecondaryContainer`)。
fn loader_row<'a>(ui: &'a Ui, catalog: Catalog, options: &'a OptionsView) -> Element<'a, Message> {
let mut line = row![]
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center);
for loader in LOADERS.iter() {
line = line.push(chip(
ui,
Chip::filter(
loader.label(catalog),
options.loader == *loader,
Message::SetLoader(*loader),
),
));
}
line.into()
}
/// 官方那份加载器版本列表(`menu` + 滚动:Fabric 有上百条,不做下拉也不铺满屏)。
fn version_list<'a>(
ui: &'a Ui,
catalog: Catalog,
options: &'a OptionsView,
) -> Element<'a, Message> {
match &options.versions {
LoaderVersionsView::Loading => frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::DownloadOptionsLoaderVersionsLoading),
ui.color(Role::OnSurfaceVariant),
),
// 取不到 / 一条都没有:原因已经在上面那行 `problem` 里说清,这里不再重复。
LoaderVersionsView::Failed(_) => frame::gap(0.0),
LoaderVersionsView::Ready(items) if items.is_empty() => frame::gap(0.0),
LoaderVersionsView::Ready(items) => {
let mut list = Menu::new().width(Length::Fill);
for item in items {
let mut entry =
MenuItem::new(item.as_str(), Message::SetLoaderVersion(item.clone()));
if *item == options.loader_version {
entry = entry.selected();
}
list = list.item(entry);
}
scrollable(menu(ui, list))
.height(Length::Fixed(ui.space(Space::X4) * VERSION_ROWS))
.style(frame::scroll_style(ui))
.into()
}
}
}
/// 「安装」:不兼容 / 列表没到位 / 已装 都按下不去(禁用态保留几何,只清消息)。
fn confirm<'a>(ui: &'a Ui, catalog: Catalog, options: &'a OptionsView) -> Element<'a, Message> {
if options.installed {
return buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstalled)));
}
if !options.can_confirm {
return buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstall)));
}
buttons::filled(
ui,
Button::new(
catalog.text(Key::DownloadInstall),
Message::InstallVersion(options.version.clone()),
)
.leading(IconKind::Download),
)
}