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 个。
325 lines
14 KiB
Rust
325 lines
14 KiB
Rust
//! 安装面板(页面底部):选中版本 → **安装选项** → 「安装」/ 进度 + 「取消」/ 失败 → 「打开日志」。
|
||
//!
|
||
//! 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;
|