Files
Unlucky-Launcher/crates/app-iced/src/pages/download/options/view.rs
T
AskaEth a16aadd467 docs(app-iced): 按 TailShare::of 的真实分支清扫下载页面板注释(r7 三条 + 全量扫)
(i) 上轮新引入的错误声明 rescope:错误行「恒为 None / 滚动区只有 head+chips」只在**三栏档 +
    带版本列表**这一支成立(has_loader_versions() 要求 problem.is_none())。**chips 档**(可用宽
    < 600,TailShare::of 对 Options/Install 无条件 Shared)里 problem 可以是 Some(静态不兼容 /
    列表取不到 / 空表三种),错误行会进滚动区且可能被滚走;show_versions() 为假时钉死区只剩
    实例名。install.rs 与 options/view.rs 都按这两支分别写清,并注明该行为域由 M227 重设计。
(ii) install.rs 的「显式给对半分档标志」「对半分:面板吃满自己那一半」改成预算优先口径
    (与同文件十行上方「不是早期的对半分」不再矛盾)。
(iii) rows.rs 三处:三栏档「照旧钉死」限定为「**不带版本列表**的选项区」;`content` 的 share
     说明改成引用 TailShare::of 的真实两支,不再复述「三栏钉死 / chips 各吃一半」;
     「只有对半分那一档才按整行取高」改成「只有 Shared(面板先保预算)那一档」。

全量扫附带修正:tail.rs 的 ROW_PITCH / PANEL_BUDGET 账(760 例改成 176/437、900 改成 569;
预算拆成滚动区 76 + 钉死区 153);options/model.rs 的 has_loader_versions 文档改成预算口径。

零代码零测试变更。
2026-11-10 19:34:15 +08:00

324 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 选项区的控件树(M105)。
//!
//! ## 版面(424 正文列里的四段)
//!
//! ```text
//! ── 滚动区(Shared 档)────────────────────────────
//! 1.12.2 [安装]
//! [原版][Forge][NeoForge][Fabric][Quilt][LiteLoader] ← 六枚一行
//! ── 钉死区 ──────────────────────────────────────
//! Vanilla parent only — Forge goes through the CLI ← 说明行;没有说明时是「加载器版本」标签
//! ┌ 1.12.2-SNAPSHOT ┐ ← 官方那份列表(menu + 滚动,按条目数占高:1 条 64 / 2 条 112)
//! └───────────────────┘
//! [实例名 ]
//! ```
//!
//! - **钉死档**(原版等不带版本列表的面板)走另一条路:标签、说明、菜单、实例名都在同一列里
//! 顺序铺开(`04` 基线图就是这一档,不能改);
//! - 不兼容时的原因行(Error 色)在滚动区末尾(**chips 档**可达;三栏档 + 带版本列表时
//! `problem` 恒为 `None`,所以那支看不到它);
//! - 说明只有 LiteLoader 有(`Key::DownloadOptionsLiteLoaderVanillaOnly`)。
//!
//! ## 为什么加载器是**六枚一行**(M222 D30 r1 实测订正)
//!
//! 这里原先写「五家一起排会溢出 ⇒ 折成两行」,那是 M105 的推算。D30 的实测图(1180×760,
//! 正文列 424)是**六枚一行、末枚右缘约 404px**,没有溢出——所以示意与说明都改成了实际形态。
//! chip 的几何仍是组件自己的真源(`ChipGeometry`),页面不许压它;再来第七家时先看图再决定
//! 是否换行。
//!
//! ## 为什么「不兼容」不禁用 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 的顺序与集合**委派**给 `Loader::ALL`(原版 / Forge / NeoForge / Fabric /
// Quilt / LiteLoader)。这里原先是手写的五枚常量——同一份清单的第三处副本
// (`dto::wizard_choices`、`Loader::ALL`、这里),M222 加 LiteLoader 时三处都得改,索性委派。
// D30 r1 实测(1180×760 与 720×760 两种宽度、浅/深两套主题):六枚 chip 排在一行,
// 末枚(LiteLoader)右缘约 404px,正文列 424 仍有余量;窄窗 720 下同一行也没有溢出。
/// 加载器版本菜单的**可见高度**(像素):最多两行项 + 上下留白 8×2。
///
/// 项高 48 是 `MenuGeometry` 的真源;`Space::X2 = 8` 是上下留白 ⇒ 两行 = 112、一行 = 64。
/// 为什么按**条目数**取(M222 D30 r1 S1):LiteLoader 对 1.12.2 只有一条版本,原来固定撑到
/// 两行高(112px),那 48px 纯属空档,却正好是把上面版本列表挤成 2.4 行的元凶之一。
/// 单测 `options/view::tests` 直接钉这两档的数值。
pub(super) fn version_menu_height(items: usize) -> f32 {
const ITEM: f32 = 48.0;
let rows = items.clamp(1, 2) as f32;
rows * ITEM + Space::X2.dp() * 2.0
}
/// 选项区(面板的下半块):**可滚的上半块 + 钉死的下半块**。
///
/// `scroll` = 面板此刻是不是「自己可滚」的那一档(`rows::TailShare::Shared`)。是的话:
///
/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips(**chips 档**还可能有错误行,见下);
/// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。
///
/// 为什么菜单也要钉(D30 四审):裁切位置由**钉死区高度**决定,与窗宽无关(1180 与 720 都切在
/// 同一个 y)。之前只钉实例名,菜单就正好落在切线上——760 下菜单容器 642–706 只有 18px 可见、
/// 条目文字一格都没画出来。把菜单一起钉住之后,它不再依赖首屏剩余高度分配,可达性由结构保证;
/// **两个 Shared 分支不一样**(`rows/tail.rs::TailShare::of`):
/// - 三栏档 + 带版本列表:`has_loader_versions()` 要求 `problem.is_none()` ⇒ 滚动区只有
/// head + chips,错误行不可达;
/// - chips 档(可用宽 < 600,`of` 对 `Options`/`Install` 无条件 Shared):`problem` 可以是
/// `Some`,错误行**会进滚动区**、可能被滚走;`show_versions()` 为假时钉死区只剩实例名。
/// D30 八审实测:滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + 间距 4)≈76px ⇒
/// 1180×760 下 head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。
pub(crate) fn section<'a>(
ui: &'a Ui,
catalog: Catalog,
options: &'a OptionsView,
scroll: bool,
) -> 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);
// 钉死档(原版 / 没有加载器版本列表)**保持旧结构**:标签与说明都在列内。
// 为什么不一并改:`04` 是别处基线图的对照,它的面板矮、列表 6 行——把标签撤出会
// 让面板矮 26px,列表涨到 302px(6.86 行)反而切出半行。方案 B 只对 Shared 档生效。
if !scroll {
let mut plain = 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 let Some(note) = &options.note {
plain = plain.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
note.as_str(),
ui.color(Role::OnSurfaceVariant),
));
}
if show_versions(options) {
plain = plain.push(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::DownloadOptionsLoaderVersion),
ui.color(Role::OnSurfaceVariant),
));
plain = plain.push(version_list(ui, catalog, options));
}
if let Some(problem) = &options.problem {
plain = plain.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
problem.as_str(),
ui.color(Role::Error),
));
}
plain = plain.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,
));
return plain.into();
}
// 滚动区只剩 head + 六枚 chips(方案 B,D30 六审裁决;chips 档还可能有错误行,见 `section` 文档)。
//
// 为什么把「安装选项」标签与中性说明都撤出滚动区:
// 五审 82px → 六审 103px 的视口里,head(40) + 标签(17) + chips(32) + 两处间距(16) = 105px,
// 差 2px 就把 chip 的底边框裁掉;再塞说明(20px)就差 27~29px,说明整块 0 像素。
// 并排标签(方案 C)在这里不可行:六枚 chip 实测已占 404px,正文列只有 424px,
// 加上「安装选项」必然横向溢出、把第六枚裁掉——那是拿一个已完成项换另一个。
// 所以按塔授权的 B 落地:**说明**搬到钉死区(见下面的 `pinned`);「安装选项」标签在
// Shared 档**删掉**(不是搬——八审裁决,见下三行),Pinned 档保留。滚动区只装控件。
// Shared 档**没有**「安装选项」标签:这是设计决定(D30 八审裁决)——六枚 chip 自解释,
// 且正文列只有 424px、六枚 chip 已占 404px,加标签必然横向裁掉最后一枚。
// 原版等 Pinned 档仍保留该标签(见上面 `!scroll` 那条路)。
let mut body = column![head, loader_row(ui, catalog, options)]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
// 中性说明**只在钉死区画一份**(八审:这里原先也画了一份,两处像素级重影)。
// 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、
// 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。
if let Some(problem) = &options.problem {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
problem.as_str(),
ui.color(Role::Error),
));
}
// 钉死区:加载器版本菜单 + 实例名(都不参与滚动)。见 `section` 的文档。
let mut pinned = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
if show_versions(options) {
// 菜单上方那一行:**有中性说明就显示说明**(它讲的是「这一家的叠法」,位置就在
// 版本菜单正上方,语义对得上);没有说明时才退回通用标签。
// 注意标签不是「搬」下来的:Shared 档的「安装选项」标签是**删掉**的(八审裁决,见上)。
// 高度相同(BodySmall 与 LabelMedium 都是一行),所以钉死区不涨。
let line = match &options.note {
Some(note) => frame::text_role(
ui,
TypeRole::BodySmall,
false,
note.as_str(),
ui.color(Role::OnSurfaceVariant),
),
None => frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::DownloadOptionsLoaderVersion),
ui.color(Role::OnSurfaceVariant),
),
};
pinned = pinned.push(line);
pinned = pinned.push(version_list(ui, catalog, options));
}
pinned = pinned.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,
));
column![
scrollable(body)
.height(Length::Fill)
.style(frame::scroll_style(ui)),
pinned,
]
.spacing(ui.space(Space::X2))
.height(Length::Fill)
.width(Length::Fill)
.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 Loader::ALL.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(version_menu_height(items.len())))
.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),
)
}