Files
Unlucky-Launcher/crates/app-iced/src/pages/download/install.rs
T
AskaEth 4f5a76f238 docs(download): r2 复审返工——改名后的旧注释三处、视口高度口径如实分档、fade 单测改成对生产值断言(M227)
P2-1(三处改名后旧注释 + 一处口径,纯文档/注释,无像素变化,未重出图):
- `install.rs` 段标题「两个 Shared 分支」→「两支「面板先保预算」的分支(Shared / Narrow)」,
  两条子项分别标注 `(Shared)` / `(Narrow)`(改名后只剩一个 Shared,另一支已是 Narrow)。
- `options/view.rs` 的 `Shared` 条目删掉「(chips 档还可能有错误行)」——chips 档被 arm 1 接走
  给 `Narrow`,`Shared` 走不到错误行;`Narrow` 条目本来就写对。
- `tests/options_shot/chips.rs` 断言③ 的注释:实测这条断言在窗口 518–610 里命中的是
  y=550、宽 94px 的**实心「安装」药丸**,版本菜单容器上沿在 636–639(窗口外)——删掉
  「菜单容器的满宽底边」这个错误替代项,并把实测值写进注释。
- 滚动区高度口径:八审原文恢复为「1180×760 ≈96–104px」(署名真实性),另起一句写 M227 在
  tip 上的复量:1180×760 = 101px、1180×900 = 109px、窄档 = 120px,并注明 r2 量的 116px
  与 109px 只差「钉死区从哪一行算起」。`DevDocs/artifacts/m227/README.md` 同句一并改。

P2-2(fade 单测空转,r2 实测变异 STEPS=6 仍绿):
- 步数与 α 曲线提成 `pub(super) const VIEWPORT_FADE_STEPS` + `pub(super) fn step_alpha(i)`,
  `viewport_fade` 用它们;单测改成对**生产值**断言四条:步数 = 高度像素数(1px/条)、
  α 严格递增且除末条外 <1、相邻增量非递减(曲线要凸)、末条 = 1.0。

变异验证(自己跑,红过再还原):
- `VIEWPORT_FADE_STEPS` 12 → 6:`panicked … 步数必须等于高度像素数:12px 要 1px 一条,
  现在 6 条 ⇒ 2px/条`,MUTATION_EXIT=101;还原 12 后 GREEN_EXIT=0。
- `t.powf(1.6)` → `t.powf(1.0)`:`panicked … 第 2 条比前一条浓得更少…曲线要凸(t^1.6)`,
  MUTATION2_EXIT=101;还原后绿。

门禁(真实退出码,CARGO_TARGET_DIR=C:/Temp/ull-target/wt-227,-j 4):
fmt 0 / check -p unlucky-app-iced --all-targets 0 / test --workspace 0(67 个 ok,
app-iced lib 875 passed,四条像素守门 + fade 单测全绿)/ clippy --workspace --all-targets
-D warnings 0 / .rs 扫描 >400 行 0 个、<2 字节 0 个。
2026-11-10 23:07:20 +08:00

325 lines
14 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 之前这里是「选中版本 → 直接『安装』」:点下去就开工,加载器维度整个不存在
//! (用户实跑的原话:「没是否有选择有前置加载器(版本选择)选项」)。选项区在 `options/`,
//! 本文件只管**面板形态的决策**(纯函数 [`panel_state`])与「安装中 / 失败」两种形态。
//!
//! ## 这一块为什么钉在底部而不是跟着列表滚
//!
//! 列表装的是几十行版本;底部按钮如果跟着滚,用户在列表中间永远看不到「安装」在哪。
//! 所以列表独立滚动(`rows::list`),面板固定占尾部一块高度。
//!
//! **M125 的例外**:chips 档(窗口 < 736,竖栏退回顶部 chip 行)里这块面板太贵——它按内容高
//! 钉死会把版本列表压成 18px 的缝。那一档改成「面板先保预算、列表按整行吃剩下的」
//! (`rows::TailShare::Narrow`,预算见 `rows/tail.rs::CHIPS_PANEL_BUDGET`)——而且只在
//! **面板真的占高度**时(`Options` / `Install`);只画一行提示的 `Hint` 态仍钉死,
//! 否则 8 个版本的列表会被砍到 4 行(r1 复审 P2-1)。
//!
//! ## 面板的滚动分层(M227 之后的实际形态)
//!
//! ```text
//! 版本行列表 scrollable ← 第 1 层(任何档位都在)
//! └ 选项区 scrollable(Shared / Narrow) ← 第 2 层(`options/view.rs::section`)
//! └ 加载器版本 menu 的 scrollable ← 第 3 层(`options/view.rs::version_menu_height`)
//! ```
//!
//! **选项区自己滚**这一层是 M222 S7/S1b 之后才有的:`rows::content` 对 `PanelState::Options`
//! 不再整块套外层 `scrollable`(只有 `Install` 态还用外层那层)。
//!
//! **鼠标落在内层上滚轮会不会冒泡到外层,本仓仍没有实测通道**(静态图拍不到滚动嵌套)。
//! iced 在指针悬停于内层时由内层消费滚动,所以「滚到底之后继续滚会带动外层」属**未验证行为**;
//! 两个方向的坏结果分别是「面板卡住滚不动」与「一滚就跳到外层」。
//! **M227 加了新的嵌套**:窄档里版本菜单在选项区的滚动区**之内**(三栏档它在钉死区、不嵌套),
//! 于是窄档是「菜单 → 选项区 → (列表外层没有)」两级嵌套,这条未验项在窄档变得更相关。
//!
//! ## 钉死页脚(M222 `6f87c8b` 起,M227 收窄)
//!
//! `options::section` 是「可滚的上半块 + 钉死的下半块」:三栏档钉死区是**说明行 + 版本菜单 +
//! 实例名**(D30 四审:菜单也钉住,否则它正好落在裁切线上);chips 档(`Narrow`)钉死区
//! **只剩实例名**——窄档的 459px 装不下「钉住菜单」+「3 整行列表」两个都要(算术见
//! `rows/tail.rs::CHIPS_PANEL_BUDGET`),于是菜单进滚动区(滚得到、滚动条可见)。
//!
//! ## 只有意图,没有乐观回显
//!
//! | 控件 | 消息 | app 层落点 |
//! |---|---|---|
//! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::CreateInstance(草稿)`(带加载器与实例名) |
//! | 「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
//! | 「打开日志」 | [`Message::Navigate`](`Route::Logs`) | `AppState::navigate`(不派指令) |
//!
//! 按钮按下**不改任何本地状态**:进度条读的是 `model.install`,它只由 presenter 的
//! `InstallProgress` 事件灌进来(`apply` → 视图模型重建)。于是「点了没变」= 后端还没回事件。
//!
//! ## 三种面板形态刻意不同构(M94 r1 / P2-3 + P2-4)
//!
//! | 状态 | 形态 | 为什么 |
//! |---|---|---|
//! | 选好了 | 版本 id + 实心「安装」 | 本页唯一的实心按钮(rubric K2) |
//! | 安装中 | 版本 id + 阶段 + **进度条**(仅当进度已知)+ 计数 + 文本「取消安装」 | 有进度才有条 |
//! | 失败 | **整块 `ErrorContainer` 面板**:`Error` 图标 + `Error` 色标题 + 停在哪 + 实心「打开日志」 | **不画进度条**——进度条正是「看起来还在跑」的来源;颜色/图标/措辞/按钮四样一起变,才一眼可辨 |
//!
//! 进度未知(刚派发、后端还没给文件计数)时**不画条**:页面没有帧订阅,`Progress::indeterminate`
//! 只会得到一个静止的假进度(复审 P2-3 的「17% 卡住」),不如只写「等第一个文件…」。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_presenter::router::Route;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::{linear, Progress};
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::model::{Download, InstallView};
use super::options::OptionsView;
use super::rows::TailShare;
/// 面板要画哪一种形态(**决策与画法分开**:决策是纯函数,单测直接钉它)。
///
/// 分开的理由就是本轮复审那件事:「装完之后面板还占着、用户再也装不了第二个版本」——
/// 这是**决策**错了,画法本身没错;把决策抽出来,测试才能在不开图形环境的情况下断言它。
#[derive(Debug, Clone, PartialEq)]
pub(super) enum PanelState<'a> {
/// 没有安装、也没选中:一句「选一个版本再安装」
Hint,
/// 选中了一个版本:给出**安装选项区**(M105;加载器 / 加载器版本 / 实例名 + 「安装」)
Options(&'a OptionsView),
/// 有安装(进行中 / 失败):进度或错误面板
Install(&'a InstallView),
}
/// 面板此刻该画哪一种(纯函数,无 iced)。
///
/// 顺序是**安装优先**:有安装时面板讲安装,讲不了列表;安装结束后 `model.install` 由
/// [`Download::from_snapshot`](super::model::Download::from_snapshot) 清掉,于是这里自然回到
/// [`PanelState::Options`] / [`PanelState::Hint`],用户能接着选下一个版本。
pub(super) fn panel_state(model: &Download) -> PanelState<'_> {
match &model.install {
Some(install) => PanelState::Install(install),
None => match &model.options {
Some(options) => PanelState::Options(options),
None => PanelState::Hint,
},
}
}
/// 底部的安装面板。
///
/// `share` = 分高方式(`rows::TailShare`,面板内部形态跟着它走):
///
/// - [`TailShare::Pinned`]:内容照原样铺开(面板按内容高钉在底部);
/// - [`TailShare::Shared`](三栏档 + 带版本列表的选项区):
/// **滚动区** = head(版本 id + 安装按钮)+ 六枚加载器 chips;
/// **钉死区** = 说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框;
/// - [`TailShare::Narrow`](chips 档 + 面板占高度):
/// **滚动区** = head + 六枚 chips + 说明行 + 版本菜单;
/// **钉死区**只剩实例名输入框(理由与预算见 `rows/tail.rs::CHIPS_PANEL_BUDGET`)。
///
/// 菜单在 `Shared` 档是钉住的(D30 四审:裁切位置由钉死区高度决定,与窗宽无关,760 下它会正好
/// 落在切线上),在 `Narrow` 档则**在滚动区里**——窄档没有多余高度同时钉住「菜单 + 3 整行列表」,
/// 而列表那 3 整行是 M125 像素守门的下限。两支的取舍都写在 `rows/tail.rs`。
///
/// **两支「面板先保预算」的分支(`Shared` / `Narrow`)的现实不同,别一概而论**
/// (`rows/tail.rs::TailShare::of`):
///
/// - **三栏档 + 带加载器版本列表**(`Shared`):`has_loader_versions()` 要求 `problem.is_none()`,
/// 所以这一支滚动区里**只会有 head + chips**,错误行与它不可达;
/// - **chips 档**(`Narrow`):`TailShare::of` 对 `Options`/`Install` 无条件 `Narrow`,
/// `problem` **可以是** `Some`(静态不兼容 / 版本列表取不到 / 空表三种),那时错误行
/// **在滚动区里**、可能被滚走;`show_versions()` 为假时滚动区只剩 head + chips + 错误行,
/// 钉死区只有实例名。
pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
panel_with(ui, catalog, model, TailShare::Pinned)
}
/// 同 [`panel`],但显式给分高方式(只有 `rows::content` 需要)。
pub(super) fn panel_with<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
share: TailShare,
) -> Element<'a, Message> {
let content = match panel_state(model) {
PanelState::Install(install) => installing(ui, catalog, install),
// 选中版本之后**先给选项,再点「安装」**(M105:以前是选中就直接开工,等于没有加载器维度)
PanelState::Options(options) => super::options::section(ui, catalog, options, share),
PanelState::Hint => hint(ui, catalog),
};
// 上下各 12 的面板留白(`[X3, 0]` 的两元组是「垂直 / 水平」)。窄档预算把它算进去了。
let panel = container(content)
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X3), 0.0]));
// 面板先保预算那两支:面板吃满自己那份(列表按整行取高之后剩下的高度),
// 选项区的 `scrollable` 才有确定高度可吃。
if matches!(share, TailShare::Pinned) {
panel.into()
} else {
panel.height(Length::Fill).into()
}
}
/// 没选中任何版本:给一句「选一个版本再安装」(不静默禁用,也不画一个点不动的按钮)。
fn hint<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::DownloadSelectHint),
ui.color(Role::OnSurfaceVariant),
)
}
/// 安装中 / 已收尾(失败另走 [`failed`])。
fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> {
if install.failed() {
return failed(ui, catalog, install);
}
let head = row![
container(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
install.version.as_str(),
ui.color(Role::OnSurface),
))
.width(Length::Fill),
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
install.stage_label.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.width(Length::Fill)
.align_y(Alignment::Center);
let mut body = column![head];
// 进度已知才画条:未知时画 indeterminate 会得到一个静止的假进度(见模块文档)。
if install.known {
body = body.push(linear(ui, Progress::determinate(install.ratio)));
}
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
install.detail.as_str(),
ui.color(Role::OnSurfaceVariant),
));
if let Some(rate) = &install.rate {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
rate.as_str(),
ui.color(Role::OnSurfaceVariant),
));
}
let action = if install.running() {
buttons::text(
ui,
Button::new(catalog.text(Key::DownloadCancel), Message::CancelInstall),
)
} else {
frame::gap(0.0)
};
column![
body.spacing(ui.space(Space::X2)).width(Length::Fill),
action
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into()
}
/// 失败态:一整块 `ErrorContainer` 面板(**没有进度条**)。
///
/// 四样一起变,保证与「正在下载」一眼可辨:容器色(`ErrorContainer`)/ 图标(`Error`)/
/// 措辞(`Install failed` 用 `Error` 色标题)/ 主按钮(实心「打开日志」,不是文本按钮)。
fn failed<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> {
let head = row![
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::DownloadStageFailed),
ui.color(Role::Error),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
let stopped = frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
format!("{} · {}", install.version, install.detail),
ui.color(Role::OnErrorContainer),
);
let actions = row![
buttons::filled(
ui,
Button::new(
catalog.text(Key::DownloadOpenLogs),
Message::Navigate(Route::Logs),
)
.leading(IconKind::Logs),
),
// 失败把面板钉住了:不给出口的话用户既重试不了、也装不了别的版本(只能重启)。
// `CancelInstall` 在 host 侧就是「清 install + 发 InstallProgress(None)」,对没在跑的任务是安全 no-op。
buttons::text(
ui,
Button::new(catalog.text(Key::DownloadDismiss), Message::CancelInstall),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
container(
column![
head,
stopped,
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::DownloadFailedHint),
ui.color(Role::OnErrorContainer),
),
actions,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill),
)
.width(Length::Fill)
.padding(ui.space(Space::X4))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
#[cfg(test)]
mod tests;