diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index 5b7e579..107c456 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -173,7 +173,7 @@ pub fn view<'a>( view_sized(ui, metrics, model, lang, 760.0) } -/// 同 [`view`],但显式给**窗口高度**(版本列表要按整行取高,见 [`rows::whole_rows`])。 +/// 同 [`view`],但显式给**窗口高度**(版本列表要按整行取高,见 [`rows::TailShare::list_height`])。 pub fn view_sized<'a>( ui: &'a Ui, metrics: FrameMetrics, @@ -247,18 +247,14 @@ fn body<'a>( rails::three_column(ui, catalog, model, rails, body_height) } else { // 三栏不成立(可用宽 < 600):类型与主版本原样退回顶部 chips,列表吃掉整条可用宽。 - // 高度分法跟着档位走:chips 档多花两行 chip,面板必须收回自己的一半(M125 缺陷 1)。 + // 高度分法跟着档位走:chips 档多花两行 chip,面板必须收回自己的预算(M125 缺陷 1)。 + // M227:这一档也改成「面板先保预算、列表按整行吃剩下的」——预算比三栏档紧得多 + // (`rows::CHIPS_PANEL_BUDGET` 192 vs `PANEL_BUDGET` 300),清单见 `rows/tail.rs`。 + let share = rows::TailShare::of(rails.tier, &install::panel_state(model)); column![ rails::stacked_chips(ui, catalog, model), divider::full(ui), - rows::content( - ui, - catalog, - model, - rows::TailShare::of(rails.tier, &install::panel_state(model)), - // chips 档不按整行取高:实测那里已经是 4.0 整行、零残影,别动。 - None, - ), + rows::content(ui, catalog, model, share, share.list_height(body_height)), ] .spacing(ui.space(Space::X2)) .width(Length::Fill) diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index 5464aa2..f91a561 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -10,15 +10,16 @@ //! 所以列表独立滚动(`rows::list`),面板固定占尾部一块高度。 //! //! **M125 的例外**:chips 档(窗口 < 736,竖栏退回顶部 chip 行)里这块面板太贵——它按内容高 -//! 钉死会把版本列表压成 18px 的缝。那一档改成「列表与面板各吃一半、面板自己可滚」 -//! (`rows::TailShare::Shared`)——但只在**面板真的占高度**时(`Options` / `Install`); -//! 只画一行提示的 `Hint` 态仍钉死,否则 8 个版本的列表会被砍到 4 行(r1 复审 P2-1)。 +//! 钉死会把版本列表压成 18px 的缝。那一档改成「面板先保预算、列表按整行吃剩下的」 +//! (`rows::TailShare::Narrow`,预算见 `rows/tail.rs::CHIPS_PANEL_BUDGET`)——而且只在 +//! **面板真的占高度**时(`Options` / `Install`);只画一行提示的 `Hint` 态仍钉死, +//! 否则 8 个版本的列表会被砍到 4 行(r1 复审 P2-1)。 //! -//! ## 面板的滚动分层(M222 D30 六轮打磨后的实际形态) +//! ## 面板的滚动分层(M227 之后的实际形态) //! //! ```text //! 版本行列表 scrollable ← 第 1 层(任何档位都在) -//! └ 选项区 scrollable(Shared 档才有) ← 第 2 层(`options/view.rs::section`,只装 head + chips) +//! └ 选项区 scrollable(Shared / Narrow) ← 第 2 层(`options/view.rs::section`) //! └ 加载器版本 menu 的 scrollable ← 第 3 层(`options/view.rs::version_menu_height`) //! ``` //! @@ -28,12 +29,15 @@ //! **鼠标落在内层上滚轮会不会冒泡到外层,本仓仍没有实测通道**(静态图拍不到滚动嵌套)。 //! iced 在指针悬停于内层时由内层消费滚动,所以「滚到底之后继续滚会带动外层」属**未验证行为**; //! 两个方向的坏结果分别是「面板卡住滚不动」与「一滚就跳到外层」。 +//! **M227 加了新的嵌套**:窄档里版本菜单在选项区的滚动区**之内**(三栏档它在钉死区、不嵌套), +//! 于是窄档是「菜单 → 选项区 → (列表外层没有)」两级嵌套,这条未验项在窄档变得更相关。 //! -//! ## 钉死页脚**已经做了**(M222 `6f87c8b` 起) +//! ## 钉死页脚(M222 `6f87c8b` 起,M227 收窄) //! -//! `options::section` 现在是「可滚的上半块 + 钉死的下半块」:钉死区内是**加载器版本菜单 + -//! 实例名输入框**(D30 四审:菜单也钉住,否则它正好落在裁切线上)。所以「实例名被滚走」 -//! 这个风险已经消除,只剩上面那条滚轮冒泡未验。 +//! `options::section` 是「可滚的上半块 + 钉死的下半块」:三栏档钉死区是**说明行 + 版本菜单 + +//! 实例名**(D30 四审:菜单也钉住,否则它正好落在裁切线上);chips 档(`Narrow`)钉死区 +//! **只剩实例名**——窄档的 459px 装不下「钉住菜单」+「3 整行列表」两个都要(算术见 +//! `rows/tail.rs::CHIPS_PANEL_BUDGET`),于是菜单进滚动区(滚得到、滚动条可见)。 //! //! ## 只有意图,没有乐观回显 //! @@ -78,6 +82,7 @@ use super::super::frame; use super::super::Message; use super::model::{Download, InstallView}; use super::options::OptionsView; +use super::rows::TailShare; /// 面板要画哪一种形态(**决策与画法分开**:决策是纯函数,单测直接钉它)。 /// @@ -110,50 +115,57 @@ pub(super) fn panel_state(model: &Download) -> PanelState<'_> { /// 底部的安装面板。 /// -/// `shared` = 选项区走「面板先保预算、列表按整行吃剩下的」那一档(`rows::TailShare::Shared` -/// 的带版本列表分支;分高规则见 `rows/tail.rs::whole_rows`,**不是**早期的「对半分」)。 -/// 此时选项区自己分成两段(`options::section`): +/// `share` = 分高方式(`rows::TailShare`,面板内部形态跟着它走): /// -/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips; -/// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。 +/// - [`TailShare::Pinned`]:内容照原样铺开(面板按内容高钉在底部); +/// - [`TailShare::Shared`](三栏档 + 带版本列表的选项区): +/// **滚动区** = head(版本 id + 安装按钮)+ 六枚加载器 chips; +/// **钉死区** = 说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框; +/// - [`TailShare::Narrow`](chips 档 + 面板占高度): +/// **滚动区** = head + 六枚 chips + 说明行 + 版本菜单; +/// **钉死区**只剩实例名输入框(理由与预算见 `rows/tail.rs::CHIPS_PANEL_BUDGET`)。 /// -/// 菜单与说明分别是 D30 四审 / 八审搬进钉死区的(裁切位置由钉死区高度决定,与窗宽无关)。 +/// 菜单在 `Shared` 档是钉住的(D30 四审:裁切位置由钉死区高度决定,与窗宽无关,760 下它会正好 +/// 落在切线上),在 `Narrow` 档则**在滚动区里**——窄档没有多余高度同时钉住「菜单 + 3 整行列表」, +/// 而列表那 3 整行是 M125 像素守门的下限。两支的取舍都写在 `rows/tail.rs`。 /// -/// **两个 Shared 分支的现实不同,别一概而论**(`rows/tail.rs::TailShare::of`): +/// **两支「面板先保预算」的分支(`Shared` / `Narrow`)的现实不同,别一概而论** +/// (`rows/tail.rs::TailShare::of`): /// -/// - **三栏档 + 带加载器版本列表**:`has_loader_versions()` 要求 `problem.is_none()`,所以这一支 -/// 滚动区里**只会有 head + chips**,错误行与它不可达; -/// - **chips 档(可用宽 < 600)**:`TailShare::of` 对 `Options`/`Install` 无条件 `Shared`, -/// `problem` **可以是** `Some`(静态不兼容 / 版本列表取不到 / 空表三种),那时错误行**会在 -/// 滚动区里出现**、可能被滚走;`show_versions()` 为假时钉死区只剩实例名(没有菜单、也没有 -/// 说明那一行)。这个行为域正好是 M227 要重新设计的地方,这里只如实记录现状。 +/// - **三栏档 + 带加载器版本列表**(`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, false) + panel_with(ui, catalog, model, TailShare::Pinned) } -/// 同 [`panel`],但显式给「面板先保预算」这一档的标志(只有 `rows::content` 需要)。 +/// 同 [`panel`],但显式给分高方式(只有 `rows::content` 需要)。 pub(super) fn panel_with<'a>( ui: &'a Ui, catalog: Catalog, model: &'a Download, - shared: bool, + 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, shared), + 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])); - // 面板先保预算档:面板吃满自己那份(`rows/tail.rs::whole_rows` 之后剩下的高度), + // 面板先保预算那两支:面板吃满自己那份(列表按整行取高之后剩下的高度), // 选项区的 `scrollable` 才有确定高度可吃。 - if shared { - panel.height(Length::Fill).into() - } else { + if matches!(share, TailShare::Pinned) { panel.into() + } else { + panel.height(Length::Fill).into() } } diff --git a/crates/app-iced/src/pages/download/options.rs b/crates/app-iced/src/pages/download/options.rs index 74dbf3a..940570e 100644 --- a/crates/app-iced/src/pages/download/options.rs +++ b/crates/app-iced/src/pages/download/options.rs @@ -27,6 +27,7 @@ //! 草稿(选中版本 + 三个选项)住在 `App`,由 `refresh_view` 带进页面模型——与分类筛选同一个做法, //! 因为 `refresh_view` 每次事件都会把整棵模型重建,页面自己存不住任何东西。 +mod fade; mod model; #[cfg(test)] mod s1_tests; diff --git a/crates/app-iced/src/pages/download/options/fade.rs b/crates/app-iced/src/pages/download/options/fade.rs new file mode 100644 index 0000000..9b6b9f7 --- /dev/null +++ b/crates/app-iced/src/pages/download/options/fade.rs @@ -0,0 +1,125 @@ +//! 选项区滚动视口底部的**淡出遮罩**(M227 返工)。 +//! +//! 单独一个文件只为守 400 行硬线(`components::gate::every_source_file_stays_under_the_line_limit`)。 + +use iced::widget::{column, container, space}; +use iced::{Color, Element, Length}; + +use unlucky_design::elevation::MaterialLayer; + +use crate::theme::Ui; + +use super::super::frame; +use super::super::Message; + +/// 窄档滚动视口底部的淡出高度(像素)。 +/// +/// 取值落在 8~12 这一段:视口在 660 处硬裁,版本菜单的容器上沿(639)露出来 21px 且**一个字形 +/// 都没有**——D30 返工把它判成「空壳蓝条」(深色下容器 `(22,33,41)` vs 画布 `(9,21,28)`, +/// CR 1.99,像渲染错而不是「下面还有内容」)。12px 遮罩让最后 12px 单调渐变到画布色, +/// 留下 8px 圆角上沿做发现性提示。 +pub(super) const VIEWPORT_FADE_PX: f32 = 12.0; + +/// 滚动视口底部的**淡出遮罩**:把内容在最后 `height` 像素内渐变到**画布色**。 +/// +/// 为什么需要它:`scrollable` 会硬裁内容(渲染层的 `with_layer`),裁切位置落在什么上面是不确定的 +/// ——窄档正落在版本菜单容器上沿,用户看到的是一条没有字形的色块,而不是「还能往下滚」。 +/// +/// 为什么是**画布色的 α 阶梯**而不是白/黑雾:遮罩的颜色必须等于它下方那块背景的颜色,否则 +/// 浅色档会浮出一层灰幕、深色档会浮出白雾。`ui.layer(MaterialLayer::Canvas)` 就是页面底色 +/// (含面板 α),于是浅色淡出到纸面、深色淡出到墨底,两档都不新增颜色;方向也对 +/// (内容向背景淡出,不是叠一层高光)。 +/// +/// 为什么用 12 条 1px 实色块拼:iced 0.14 没有渐变原语。1px 步进的 α 斜坡在 100% DPI 下与 +/// 连续渐变的差别在 1/255 量级——D30 的逐像素量测只会看到 α 单调、无硬边。 +/// +/// 形状上它必须**贴在视口内**(调用侧把它放在滚动区底部的 `stack!` 上):视口下沿是 660, +/// 钉死区的实例名从 668 起,所以遮罩压不到任何控件(M227 返工的零回退证据就是逐像素 +/// 差异只有 648–660 这 12 行)。 +pub(super) fn viewport_fade<'a>(ui: &'a Ui, height: f32) -> Element<'a, Message> { + let canvas = ui.layer(MaterialLayer::Canvas); + let band_height = height / VIEWPORT_FADE_STEPS as f32; + + let mut bands = column![].width(Length::Fill); + for index in 0..VIEWPORT_FADE_STEPS { + let alpha = canvas.a * step_alpha(index); + bands = bands.push( + container(space::horizontal()) + .width(Length::Fill) + .height(Length::Fixed(band_height)) + .style(move |_theme| frame::filled(Color { a: alpha, ..canvas })), + ); + } + bands.into() +} + +/// 遮罩的条数:每条高 `VIEWPORT_FADE_PX / VIEWPORT_FADE_STEPS` 像素(现在是 1px)。 +/// +/// 单测直接对**这一对生产常量** + [`step_alpha`] 断言,所以改步数或改曲线都会当场变红 +/// ——M227 r2 复审实测过「单测空转」的形态:旧测试在函数体里又抄了一遍 `12`,改生产常量它照样绿。 +pub(super) const VIEWPORT_FADE_STEPS: usize = 12; + +/// 第 `index` 条(0 = 最上面、`VIEWPORT_FADE_STEPS - 1` = 视口底边)的**不透明度系数**。 +/// +/// 顶端 ≈0(几乎透出内容)→ 底端 = 1(完全是画布色,与视口外的背景接上);`t^1.6` 让前几条更淡, +/// 观感是「内容渐隐到背景」,不是一层均匀的纱。 +pub(super) fn step_alpha(index: usize) -> f32 { + // t:0 < t <= 1(`index + 1` 让顶端不落在 0 上,否则第一条完全看不见)。 + let t = (index + 1) as f32 / VIEWPORT_FADE_STEPS as f32; + t.powf(1.6) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 遮罩的几何自检:**对生产常量断言**,不是对测试里抄的副本。 + /// + /// 抓得住(r2 复审指出的空转形态:旧测试在函数体里又抄了一遍 `12`,改生产常量照样绿): + /// `VIEWPORT_FADE_STEPS` 改成 6(每档 2px ⇒ 见得到阶梯)、改成不能整除高度的数(半像素带)、 + /// `step_alpha` 写成常数(等于一层纯色幕)、把曲线拉直(`^1.0`,前几条不再更淡)、 + /// 或让末项小于 1(视口底边与画布之间留一道缝)。 + #[test] + fn the_fade_bands_are_one_pixel_and_the_ramp_is_convex_to_opaque() { + // ① 每条**恰好 1px**:这是设计口径(「12 条 1px 实色块」),也是「肉眼分不出阶梯」的前提。 + // 换成 6 条就是 2px/条、换成 5 条还会出半像素带 —— 两种都当场红。 + let band = VIEWPORT_FADE_PX / VIEWPORT_FADE_STEPS as f32; + assert_eq!( + VIEWPORT_FADE_STEPS as f32, VIEWPORT_FADE_PX, + "步数必须等于高度像素数:{VIEWPORT_FADE_PX}px 要 1px 一条,现在 {VIEWPORT_FADE_STEPS} 条 ⇒ {band}px/条" + ); + assert_eq!(band, 1.0, "每条恰好 1px"); + + // ② α 严格递增、除末条外都不透明(末条必须是画布色本身,与视口外无缝接上)。 + let mut previous = 0.0_f32; + let mut previous_delta = 0.0_f32; + for index in 0..VIEWPORT_FADE_STEPS - 1 { + let alpha = step_alpha(index); + let delta = alpha - previous; + assert!( + delta > 0.0, + "第 {index} 条的 α ({alpha}) 必须大于前一条 ({previous}):遮罩要单调变浓" + ); + assert!( + alpha < 1.0, + "第 {index} 条就已不透明,后面 {VIEWPORT_FADE_STEPS} 条白给" + ); + assert!( + delta >= previous_delta, + "第 {index} 条比前一条浓得更少({delta} < {previous_delta}):曲线要凸(t^1.6),否则顶端不再「几乎透出内容」" + ); + previous = alpha; + previous_delta = delta; + } + assert!( + step_alpha(0) < 0.1, + "最上面一条 α = {} 不够淡:遮罩顶端要几乎透出内容", + step_alpha(0) + ); + assert_eq!( + step_alpha(VIEWPORT_FADE_STEPS - 1), + 1.0, + "最末一条必须是画布色本身(否则与视口外的背景之间有一道缝)" + ); + } +} diff --git a/crates/app-iced/src/pages/download/options/s1_tests.rs b/crates/app-iced/src/pages/download/options/s1_tests.rs index 9159da6..eff1819 100644 --- a/crates/app-iced/src/pages/download/options/s1_tests.rs +++ b/crates/app-iced/src/pages/download/options/s1_tests.rs @@ -28,14 +28,16 @@ fn version_menu_height_counts_whole_items_only() { ); } -/// S1(D30 r1)的装配判据:**带加载器版本列表**的选项区在任何档位都与列表对半分。 +/// S1(D30 r1)的装配判据:**带加载器版本列表**的选项区在三栏档与列表共享高度。 /// /// 实测背景(1180×760):选项区从 204px 长到 372px 时,版本列表被压成 2.4 行(110px), -/// 底缘切在第三行图标上;对半分之后列表回到 4.6 行、面板自己可滚。 +/// 底缘切在第三行图标上;改成分高之后列表回到 4.6 行、面板自己可滚。 /// -/// 抓得住:有人把这条规则删掉(`TailShare::of` 只判 chips 档)——列表又会掉回 2 行; -/// 或者把它放宽成「任何选项区都对半分」(原版没有版本列表时不该对半分,那会把 6 行列表 -/// 砍成 4 行)。 +/// M227 起 chips 档单独一支(`Narrow`):同样的「先保预算」思路,但钉死区只留实例名, +/// 因为窄档 459px 装不下「钉住菜单 + 3 整行列表」(算术见 `rows/tail.rs::CHIPS_PANEL_BUDGET`)。 +/// +/// 抓得住:有人把 arm 2 删掉(`TailShare::of` 只判 chips 档)——三栏档列表又会掉回 2 行; +/// 或者把它放宽成「任何选项区都共享」(原版没有版本列表时不该共享,那会把 6 行列表砍成 4 行)。 #[test] fn tall_options_panel_shares_height_at_every_tier() { use crate::pages::download::install::PanelState; @@ -61,14 +63,19 @@ fn tall_options_panel_shares_height_at_every_tier() { VersionRailTier::Compact, VersionRailTier::Chips, ] { + let expected = if tier == VersionRailTier::Chips { + TailShare::Narrow + } else { + TailShare::Shared + }; assert_eq!( TailShare::of(tier, &PanelState::Options(&tall)), - TailShare::Shared, - "{tier:?}:带版本列表的选项区必须与列表对半分" + expected, + "{tier:?}:带版本列表的选项区必须与列表共享高度" ); } - // 原版没有版本列表 ⇒ 仍然钉死(三栏档不该无谓对半分)。 + // 原版没有版本列表 ⇒ 仍然钉死(三栏档不该无谓共享)。 let plain_state = AppState::new(); let plain = OptionsView::of( &version_row, @@ -85,7 +92,7 @@ fn tall_options_panel_shares_height_at_every_tier() { ); } - // 不兼容(有 problem)时列表不画 ⇒ 也不该对半分。 + // 不兼容(有 problem)时列表不画 ⇒ 也不该共享。 let blocked_state = state_with(LoaderKind::LiteLoader, "1.13", Ok(Vec::new())); let blocked = OptionsView::of( &row("1.13", VersionKind::Release), diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 41b8c64..839bec6 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -1,9 +1,9 @@ -//! 选项区的控件树(M105)。 +//! 选项区的控件树(M105;窄档形态 M227)。 //! -//! ## 版面(424 正文列里的四段) +//! ## 版面(424 正文列里的四段,三栏档 `Shared`) //! //! ```text -//! ── 滚动区(Shared 档)──────────────────────────── +//! ── 滚动区 ────────────────────────────────────── //! 1.12.2 [安装] //! [原版][Forge][NeoForge][Fabric][Quilt][LiteLoader] ← 六枚一行 //! ── 钉死区 ────────────────────────────────────── @@ -13,11 +13,29 @@ //! [实例名 ] //! ``` //! -//! - **钉死档**(原版等不带版本列表的面板)走另一条路:标签、说明、菜单、实例名都在同一列里 +//! 窄档(chips 档,可用宽 < 600,`rows::TailShare::Narrow`)**钉死区只剩实例名**,说明行与 +//! 菜单都进滚动区——那里的高度只够「钉住实例名 + 3 整行版本列表」,算术见 +//! `rows/tail.rs::CHIPS_PANEL_BUDGET`: +//! +//! ```text +//! ── 滚动区(首屏 104px = head 40 + 间距 4 + chips 32 + 间距 8 + 说明 20)── +//! 1.12.2 [安装] +//! [原版][Forge][NeoForge][Fabric][Quilt][LiteLoader] +//! Vanilla parent only — Forge goes through the CLI +//! ┌ 1.12.2-SNAPSHOT ┐ ← 露在折线下方,滚得到(滚动条在右) +//! ── 视口底 12px 淡出遮罩(`fade::viewport_fade`)──── +//! ── 钉死区 ────────────────────────────────────── +//! [实例名 ] +//! ``` +//! +//! - **钉死档**(原版等不带版本列表的面板)走第三条路:标签、说明、菜单、实例名都在同一列里 //! 顺序铺开(`04` 基线图就是这一档,不能改); -//! - 不兼容时的原因行(Error 色)在滚动区末尾(**chips 档**可达;三栏档 + 带版本列表时 +//! - 不兼容时的原因行(Error 色)在滚动区末尾(**窄档**可达;三栏档 + 带版本列表时 //! `problem` 恒为 `None`,所以那支看不到它); -//! - 说明只有 LiteLoader 有(`Key::DownloadOptionsLiteLoaderVanillaOnly`)。 +//! - 说明只有 LiteLoader 有(`Key::DownloadOptionsLiteLoaderVanillaOnly`); +//! - 窄档的滚动视口底边会硬裁内容(M227 返工前正切在版本菜单容器上沿,露出 21px 没有字形的 +//! 色块),所以那里铺一条 12px 淡出遮罩把内容渐变到画布色——遮罩在视口内(≤660), +//! 压不到钉死区的实例名(≥668)。 //! //! ## 为什么加载器是**六枚一行**(M222 D30 r1 实测订正) //! @@ -32,7 +50,7 @@ //! 现在的做法:五家永远都能点,选中不兼容的那家时下面出现一行具名原因,同时「安装」变成禁用态。 //! 于是「为什么不能装」和「怎么才能装」永远同屏。 -use iced::widget::{column, container, row, scrollable}; +use iced::widget::{column, container, row, scrollable, stack}; use iced::{Alignment, Element, Length}; use unlucky_design::color::Role; @@ -49,7 +67,9 @@ use crate::app::components::selection::{chip, Chip}; use crate::theme::Ui; use super::super::frame; +use super::super::rows::TailShare; use super::super::Message; +use super::fade; use super::model::{Loader, LoaderVersionsView, OptionsView}; // 加载器 chip 的顺序与集合**委派**给 `Loader::ALL`(原版 / Forge / NeoForge / Fabric / @@ -72,26 +92,36 @@ pub(super) fn version_menu_height(items: usize) -> f32 { /// 选项区(面板的下半块):**可滚的上半块 + 钉死的下半块**。 /// -/// `scroll` = 面板此刻是不是「自己可滚」的那一档(`rows::TailShare::Shared`)。是的话: +/// `share` = 分高方式(`rows::TailShare`,三个形态见 `install::panel_with` 的文档): /// -/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips(**chips 档**还可能有错误行,见下); -/// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。 +/// - `Pinned`(原版等不带版本列表的面板)走另一条路:标签、说明、菜单、实例名都在同一列里 +/// 顺序铺开(`04` 基线图就是这一档,不能改); +/// - `Shared`(三栏档 + 带版本列表): +/// - **滚动区**:head(版本 id + 安装按钮)+ 六枚加载器 chips; +/// - **钉死区**:说明行(有说明时;否则是「加载器版本」标签)+ 版本菜单 + 实例名输入框。 +/// - `Narrow`(chips 档 + 面板占高度,M227): +/// - **滚动区**:head + 六枚 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 四角闭合)完整在首屏。 +/// 为什么菜单在三栏档钉、在窄档滚:裁切位置由**钉死区高度**决定(D30 四审), +/// 760 下菜单曾正好落在切线上(容器 642–706 只有 18px 可见、条目文字一格没画出来)。 +/// 三栏档把菜单一起钉住之后不再依赖首屏剩余高度分配;窄档没有那么多高度—— +/// 钉住菜单 + 说明 + 实例名要 180~228px,再加首屏 head+chips 76 就把列表挤到 ≤1 整行, +/// 而 M125 的像素守门要求 ≥3 整行。所以窄档只钉实例名,其余进滚动区(预算算术见 +/// `rows/tail.rs::CHIPS_PANEL_BUDGET`)。 +/// +/// D30 八审实测(1180×760,`Shared`):滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + +/// 间距 4)≈76px ⇒ head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。 +/// M227 在 tip 上按同一条判据复量(视口 = 面板内容顶 → 钉死区上一行 − 8px 间距): +/// 1180×760 = 462→563 = **101px**、1180×900 = 594→703 = **109px**、窄档 720×760 = 539→659 = +/// **120px**(r2 复审量的 116px 落在这个区间内,差别只在「钉死区从哪一行算起」;三档都远大于 +/// 首屏内容 76px,结论不变)。窄档那 120px 对首屏 104px(head 40 + 4 + chips 32 + 8 + 说明行 20) +/// 留 16px 余量 ⇒ head、六枚 chips、说明行三者完整在首屏,版本菜单露出容器上沿(滚动条在右)。 pub(crate) fn section<'a>( ui: &'a Ui, catalog: Catalog, options: &'a OptionsView, - scroll: bool, + share: TailShare, ) -> Element<'a, Message> { let head = row![ container(frame::text_role( @@ -109,8 +139,8 @@ pub(crate) fn section<'a>( // 钉死档(原版 / 没有加载器版本列表)**保持旧结构**:标签与说明都在列内。 // 为什么不一并改:`04` 是别处基线图的对照,它的面板矮、列表 6 行——把标签撤出会 - // 让面板矮 26px,列表涨到 302px(6.86 行)反而切出半行。方案 B 只对 Shared 档生效。 - if !scroll { + // 让面板矮 26px,列表涨到 302px(6.86 行)反而切出半行。方案 B 只对共享档生效。 + if matches!(share, TailShare::Pinned) { let mut plain = column![ head, frame::text_role( @@ -163,25 +193,32 @@ pub(crate) fn section<'a>( return plain.into(); } - // 滚动区只剩 head + 六枚 chips(方案 B,D30 六审裁决;chips 档还可能有错误行,见 `section` 文档)。 + // 共享档的滚动区以 head + 六枚 chips 起头(方案 B,D30 六审裁决;错误行见下)。 // - // 为什么把「安装选项」标签与中性说明都撤出滚动区: - // 五审 82px → 六审 103px 的视口里,head(40) + 标签(17) + chips(32) + 两处间距(16) = 105px, - // 差 2px 就把 chip 的底边框裁掉;再塞说明(20px)就差 27~29px,说明整块 0 像素。 - // 并排标签(方案 C)在这里不可行:六枚 chip 实测已占 404px,正文列只有 424px, + // 为什么把「安装选项」标签撤出滚动区:五审 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 自解释, + // 共享档**删掉**(不是搬——八审裁决,见下三行),Pinned 档保留。滚动区只装控件。 + // 共享档**没有**「安装选项」标签:这是设计决定(D30 八审裁决)——六枚 chip 自解释, // 且正文列只有 424px、六枚 chip 已占 404px,加标签必然横向裁掉最后一枚。 - // 原版等 Pinned 档仍保留该标签(见上面 `!scroll` 那条路)。 + // 原版等 Pinned 档仍保留该标签(见上面 `Pinned` 那条路)。 + let narrow = matches!(share, TailShare::Narrow); let mut body = column![head, loader_row(ui, catalog, options)] .spacing(ui.space(Space::X1)) .width(Length::Fill); - // 中性说明**只在钉死区画一份**(八审:这里原先也画了一份,两处像素级重影)。 - // 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、 - // 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。 + // 窄档:说明行(或「加载器版本」标签)与版本菜单**都进滚动区**——钉死区只剩实例名。 + // 顺序必须是 head → chips → 说明 → 菜单:预算(`CHIPS_PANEL_BUDGET`)保证前 104px 完整, + // 菜单露在折线下方(滚动条可见,滚得到)。 + if narrow && show_versions(options) { + body = body.push(versions_label(ui, catalog, options)); + } + + // 中性说明在三栏档**只在钉死区画一份**(八审:这里原先也画了一份,两处像素级重影)。 + // 窄档没有钉死的那一行(见上),所以它在这里画、也只画一次。 if let Some(problem) = &options.problem { body = body.push(frame::text_role( ui, @@ -192,30 +229,14 @@ pub(crate) fn section<'a>( )); } - // 钉死区:加载器版本菜单 + 实例名(都不参与滚动)。见 `section` 的文档。 + if narrow && show_versions(options) { + body = body.push(version_list(ui, catalog, options)); + } + + // 钉死区:三栏档是「说明/标签 + 菜单 + 实例名」,窄档只剩实例名。见 `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); + if !narrow && show_versions(options) { + pinned = pinned.push(versions_label(ui, catalog, options)); pinned = pinned.push(version_list(ui, catalog, options)); } pinned = pinned.push(text_field( @@ -227,16 +248,62 @@ pub(crate) fn section<'a>( 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() + let scroll_area: Element<'a, Message> = scrollable(body) + .height(Length::Fill) + .style(frame::scroll_style(ui)) + .into(); + + // 窄档:视口底部铺一条 12px 淡出遮罩(M227 返工 ①,理由见 `fade::viewport_fade`)。 + // 三栏档不铺:那里滚动区内容恒为 head 40 + 间距 4 + chips 32 = 76px,而视口 ≈118px, + // 永远不会出现「半截内容」;铺上去只是「画布盖画布」,反而动了 M222 已验的 1180 像素。 + let scroll_area: Element<'a, Message> = if narrow { + stack![ + scroll_area, + container(fade::viewport_fade(ui, fade::VIEWPORT_FADE_PX)) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::End), + ] + .width(Length::Fill) + .height(Length::Fill) + .into() + } else { + scroll_area + }; + + column![scroll_area, pinned] + .spacing(ui.space(Space::X2)) + .height(Length::Fill) + .width(Length::Fill) + .into() +} + +/// 版本菜单上方那一行:**有中性说明就显示说明**(它讲的是「这一家的叠法」,位置就在版本菜单 +/// 正上方,语义对得上);没有说明时才退回通用标签。 +/// +/// 高度相同(`BodySmall` 与 `LabelMedium` 都是一行),所以无论放滚动区还是钉死区都不改预算。 +/// 注意标签不是「搬」下来的:共享档的「安装选项」标签是**删掉**的(八审裁决,见 `section`)。 +fn versions_label<'a>( + ui: &'a Ui, + catalog: Catalog, + options: &'a OptionsView, +) -> Element<'a, Message> { + 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), + ), + } } /// 加载器版本这一段要不要画(见 `section` 的注释)。 diff --git a/crates/app-iced/src/pages/download/rails.rs b/crates/app-iced/src/pages/download/rails.rs index afa6c91..7b664e1 100644 --- a/crates/app-iced/src/pages/download/rails.rs +++ b/crates/app-iced/src/pages/download/rails.rs @@ -77,6 +77,8 @@ pub(super) fn three_column<'a>( rails: VersionRails, body_height: f32, ) -> Element<'a, Message> { + // 分高方式与列表高度都从 `TailShare` 一处取(`Pinned` 档不给整行高度)。 + let share = rows::TailShare::of(rails.tier, &super::install::panel_state(model)); row![ panel( ui, @@ -92,8 +94,8 @@ pub(super) fn three_column<'a>( ui, catalog, model, - rows::TailShare::of(rails.tier, &super::install::panel_state(model)), - Some(rows::whole_rows(body_height)), + share, + share.list_height(body_height), )) .width(Length::Fixed(rails.list)) .height(Length::Fill), diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 7019961..13eeb94 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -29,11 +29,14 @@ //! 都在面板钉死区,见 `options/view.rs::section`)。 //! 同样的 760 高窗口里:三栏档列表还剩 158px(3 整行),chips 档只剩 18px。 //! -//! 修法是 [`TailShare`] 两档:**不带加载器版本列表**的选项区在三栏档照旧钉死(面板整块在屏内), +//! 修法是 [`TailShare`] 三档:**不带加载器版本列表**的选项区在三栏档照旧钉死(面板整块在屏内), //! chips 档**且面板真的占高度** //! (选项区展开 / 安装中)时改成「**面板先保预算、剩下按整行给列表**」 -//! (`tail::whole_rows` + `PANEL_BUDGET = 300`):1180×760 下列表 **176px = 4 整行**, -//! 面板拿走其余(≈307px)。实例名那格**已经钉死**在面板底部、不需要滚(M222 `6f87c8b`)。 +//! (`tail::narrow_rows` + `CHIPS_PANEL_BUDGET = 192`):700/720×760 下列表 **132px = 3 整行**、 +//! 面板拿走其余(208px)。M227 之前这一档是「Fill 与 Fill 五五分」——面板只分到 164px, +//! 钉死区(说明 + 菜单 + 实例名 ≈180px)把它吃穿,滚动区剩 0、head / 六枚 chip 全不在屏。 +//! 三栏档 + 带版本列表的选项区走另一支(`tail::whole_rows` + `PANEL_BUDGET = 300`): +//! 1180×760 列表 **176px = 4 整行**、面板 ≈294px,那里菜单与说明都钉在面板底部。 //! 「刚进页面还没选版本」(`Hint`,面板只有一行提示)**不共享**:那一态 main 本来就有 //! **7 整行**在屏(不是 8:第 8 条墨迹其实是 `Pick a version, then install` 那句 hint, //! 横向范围对不上版本号;700×760 上放 8 行要 352px,实测只剩 309~322px),五五分会砍成 @@ -82,13 +85,14 @@ use super::states; mod tail; -pub(crate) use tail::{whole_rows, TailShare}; +pub(crate) use tail::TailShare; /// 列表列(三栏档与 chips 档共用一份)。 /// /// `share` 决定「列表」与「尾部面板」怎么分这块高度(真实分支见 `tail::TailShare::of`): -/// `Pinned` = 面板按内容高钉在底部、列表吃剩下的;`Shared` = 面板先吃 `tail::PANEL_BUDGET`、 -/// 列表按**整行**吃剩下的(`tail::whole_rows`)。哪些组合走哪一支由那一处决定,这里不重复判。 +/// `Pinned` = 面板按内容高钉在底部、列表吃剩下的;`Shared` / `Narrow` = 面板先吃预算、 +/// 列表按**整行**吃剩下的(`tail::whole_rows` / `tail::narrow_rows`,两者差别在面板内部 +/// 钉什么,见 `tail::CHIPS_PANEL_BUDGET`)。哪些组合走哪一支由那一处决定,这里不重复判。 pub(super) fn content<'a>( ui: &'a Ui, catalog: Catalog, @@ -105,40 +109,38 @@ pub(super) fn content<'a>( } else if visible.is_empty() { states::empty(ui, catalog) } else { - // 只有 `Shared`(面板先保预算)那一档才按整行取高:`Pinned` 档(面板矮)实测已经 - // 是整行(`04` 图),取整反而会把它从 6 行缩到 5 行。 - let height = if matches!(share, TailShare::Shared) { - list_height - } else { + // 只有「面板先保预算」那两支(`Shared` / `Narrow`)才按整行取高:`Pinned` 档(面板矮) + // 实测已经是整行(`04` 图),取整反而会把它从 6 行缩到 5 行。 + let height = if matches!(share, TailShare::Pinned) { None + } else { + list_height }; list(ui, catalog, model, &visible, height) }; // 列表与尾部面板之间**总是**有一条分隔线(三态一致):M123 的取证图里「选好了版本」 // 那一态原先没有它,版本行与选项区第一行(版本号 + 安装按钮)读起来像同一块内容。 - let shared = matches!(share, TailShare::Shared); let tail: Element<'a, Message> = column![ divider::full(ui), - install::panel_with(ui, catalog, model, shared) + install::panel_with(ui, catalog, model, share) ] .spacing(ui.space(Space::X3)) .width(Length::Fill) .into(); - // chips 档 / 带版本列表的选项区:面板收回自己的一半。 + // chips 档 / 带版本列表的选项区:面板吃满自己那份。 // // `PanelState::Options` 走**面板内部**的滚动(上半块可滚 + 实例名钉页脚,见 `install::panel`), // 所以这里不再整块套一层 scrollable——那一层曾让窄窗出现两条并列滚动条,也因为把实例名 // 卷进滚动区而让它掉到首屏外(D30 r2 S7)。其它面板(安装中/失败)内容短,保留外层滚动。 let tail: Element<'a, Message> = match (share, panel_state(model)) { - (TailShare::Shared, PanelState::Options(_)) => tail, - (TailShare::Shared, _) => scrollable(tail) + (TailShare::Pinned, _) | (_, PanelState::Options(_)) => tail, + (TailShare::Shared | TailShare::Narrow, _) => scrollable(tail) .id(iced::widget::Id::new(PANEL_SCROLL_ID)) .height(Length::Fill) .style(frame::scroll_style(ui)) .into(), - (TailShare::Pinned, _) => tail, }; column![ @@ -273,7 +275,8 @@ fn list<'a>( .scroller_width(frame::SCROLLBAR_WIDTH) .margin(frame::SCROLLBAR_MARGIN), )) - // `Some` = 三栏档的 `Shared` 分支按**整行**取高(见 `tail::whole_rows`);`None` = 其它(含 chips 档)用 Fill。 + // `Some` = 面板先保预算那一档按**整行**取高(三栏档 `tail::whole_rows` / + // chips 档 `tail::narrow_rows`);`None` = `Pinned`(面板矮)用 Fill。 .height(height.map_or(Length::Fill, Length::Fixed)) .style(frame::scroll_style(ui)) .into() diff --git a/crates/app-iced/src/pages/download/rows/tail.rs b/crates/app-iced/src/pages/download/rows/tail.rs index b5ad9a3..859e8a6 100644 --- a/crates/app-iced/src/pages/download/rows/tail.rs +++ b/crates/app-iced/src/pages/download/rows/tail.rs @@ -1,85 +1,111 @@ -//! 「列表与尾巴面板怎么分高」+ 版本行的整行取整(M125 / M222 D30)。 +//! 「列表与尾巴面板怎么分高」+ 版本行的整行取整(M125 / M222 D30 / M227)。 //! //! 从 `rows.rs` 拆出来的理由只有一条:那边撞 400 行硬线(项目自带 //! `components::gate::every_source_file_stays_under_the_line_limit` 会判红)。本文件是 //! [`TailShare`] 的**纯搬移**,加上 M222 新增的三个只读量([`ROW_PITCH`] / [`LIST_CHROME`] / -//! [`whole_rows`],见 `rows.rs::content` 的用法)。 +//! [`whole_rows`])与 M227 的 chips 档预算([`CHIPS_STACK`] / [`CHIPS_LIST_CHROME`] / +//! [`CHIPS_PANEL_BUDGET`] / [`narrow_rows`])。 use crate::pages::layout::VersionRailTier; use crate::pages::download::install::PanelState; -/// 列表与尾部面板怎么分这块高度(M125 缺陷 1 的修法)。 +/// 列表与尾部面板怎么分这块高度(M125 缺陷 1 的修法;M227 拆出 chips 档一支)。 /// -/// | 档 | 分法 | 为什么 | +/// | 档位 × 面板形态 | 分法 | 为什么 | /// |---|---|---| -/// | `Wide` / `Narrow` / `Compact` + 面板**不带**加载器版本列表 | [`TailShare::Pinned`]:面板按内容高钉在底部,列表吃剩下的 | 原版面板矮(`04` 实测列表 6 行),整块在屏内 | -/// | 任何档 + 选项区**带加载器版本列表** | [`TailShare::Shared`]:面板先吃 [`PANEL_BUDGET`],剩下按整行给列表([`whole_rows`]) | 带版本列表的选项区实高 ≈307px,对半分(旧规则)把列表压到 110px;现在 1180×760 是 **176px = 4 整行** | -/// | `Chips` + 面板**占高度**(选项区展开 / 安装中) | [`TailShare::Shared`] | chips 档比三栏档多花两行 chip,列表只剩 **18px**(M123 实测 0.4 行)——版本号读不出来就换不了版本 | -/// | 任何档 + 只画 `Hint`(没选版本,面板只有一行) | [`TailShare::Pinned`] | 一行提示不值得对半分:700×760 下 main 是 **7 整行**在屏(不是 8——第 8 条墨迹其实是 `Pick a version, then install` 那句 hint,横向范围对不上版本号;要放 8 行得 352px,实测只剩 309~322px),对半分只剩 **4 整行**、下面留 160px 空白(r1 复审 P2-1,r2 修回 7) | +/// | `Wide` / `Narrow` / `Compact` + 面板**不带**加载器版本列表 | [`TailShare::Pinned`] | 原版面板矮(`04` 实测列表 6 行),整块在屏内 | +/// | 任何档 + 选项区**带加载器版本列表** | [`TailShare::Shared`]:面板先吃 [`PANEL_BUDGET`],剩下按整行给列表([`whole_rows`]) | 带版本列表的选项区实高 ≈294~307px;旧规则(对半分)把列表压到 110px,现在 1180×760 是 **176px = 4 整行** | +/// | `Chips` + 面板**占高度**(选项区展开 / 安装中) | [`TailShare::Narrow`]:面板先吃 [`CHIPS_PANEL_BUDGET`],剩下按整行给列表([`narrow_rows`]) | 窄档可用高比三栏档少两行 chip(459 vs 580)⇒ 只钉实例名,head/chips/说明/菜单进滚动区;700/720×760 列表 **132px = 3 整行** | +/// | 任何档 + 只画 `Hint`(没选版本,面板只有一行) | [`TailShare::Pinned`] | 一行提示不值得让面板吃预算:700×760 下 main 是 **7 整行**在屏(不是 8——第 8 条墨迹其实是 `Pick a version, then install` 那句 hint,横向范围对不上版本号;要放 8 行得 352px,实测只剩 309~322px),对半分只剩 **4 整行**、下面留 160px 空白(r1 复审 P2-1,r2 修回 7) | /// /// 分高方式按「档位 + 面板形态」判;**具体像素**由窗口高度算(`pages/download.rs::view_sized` -/// 把 `model.window_height` 经 `frame::body_height` 传下来,见 [`whole_rows`])。 +/// 把 `model.window_height` 经 `frame::body_height` 传下来,见 [`whole_rows`] / [`narrow_rows`])。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TailShare { - /// 面板钉在底部(三栏档)。 + /// 面板钉在底部(面板矮的档位)。 Pinned, - /// 面板先吃预算、列表按整行吃剩下的(带版本列表的选项区 / chips 档占高度时)。 + /// 三栏档 + 带版本列表的选项区:面板先吃预算、列表按整行吃剩下的;钉死区含**说明/标签 + /// + 版本菜单 + 实例名**(D30 四审/八审判定:1180 档高度够,菜单钉住最省事)。 Shared, + /// chips 档(可用宽 < 600)+ 面板占高度:同一套「先保预算、列表按整行」, + /// 但面板钉死区**只留实例名**,head / 六枚 chip / 说明 / 版本菜单全在滚动区 + /// (理由见 [`CHIPS_PANEL_BUDGET`]:459 里装不下「钉住菜单」+「3 整行列表」)。 + Narrow, } impl TailShare { /// 档位 + **面板形态** → 分高方式(纯函数,单测直接钉它)。 /// - /// 两个条件必须同时成立才共享: - /// 1. 档位是 `Chips`(多花两行 chip,竖排预算最紧的那一档); - /// 2. 面板真的**占高度**——`Options`(选中了版本、选项区展开)或 `Install`(进度 / 失败)。 + /// 三条 arm,**不是**「两个条件同时成立」才共享(M227 重写:旧文档只写 M125 那一条, + /// 把 arm 2 的「任何档位」漏了 —— r8 复审 improve finding): + /// 1. `Chips`(可用宽 < 600,多花两行 chip、竖排预算最紧的那一档)**且**面板真占高度 + /// (`Options` 选中了版本 / `Install` 进度或失败)⇒ [`TailShare::Narrow`]; + /// 2. **任何档位**的 `Options` 只要 [`has_loader_versions`](super::super::options::OptionsView::has_loader_versions) + /// (选项区带加载器版本列表 ⇒ 实高 ≈294~307px)⇒ [`TailShare::Shared`]。三栏档只走这条, + /// chips 档被 arm 1 先接走; + /// 3. 其余 ⇒ [`TailShare::Pinned`]:不带版本列表的 `Options`(原版 / 不兼容 / 空表)与 + /// `Hint`。 /// - /// `Hint`(刚进页面还没选版本)只是**一行**提示(≈30px),对半分会让列表从 **7 整行** - /// 掉到 **4 整行**、下半块留 ≈160px 空画布(r1 复审 P2-1 实测:main 在 700×760 的 + /// `Hint`(刚进页面还没选版本)只是**一行**提示(≈30px),让它吃预算会把列表从 **7 整行** + /// 砍到 **4 整行**、下半块留 ≈160px 空画布(r1 复审 P2-1 实测:main 在 700×760 的 /// `Hint` 态是 7 整行——不是 8,第 8 条墨迹其实是 `Pick a version, then install` 那句 /// hint,横向范围对不上版本号;r2 修回 7 整行)。所以 `Hint` **在任何档位都钉死**。 pub(crate) fn of(tier: VersionRailTier, state: &PanelState<'_>) -> Self { match (tier, state) { + // M227:chips 档(窄档)单独一支 —— 同一份「先保预算」思路,但钉死区只留实例名。 (VersionRailTier::Chips, PanelState::Options(_) | PanelState::Install(_)) => { - Self::Shared + Self::Narrow } // M222 D30 r1/r2:**带加载器版本列表**的选项区在任何档位都太高。三栏档修前 // 面板 204→372px,版本列表被压到 2.4 行(110px)且底缘切在第三行图标上。 // 现在走 Shared:面板先吃 `PANEL_BUDGET`,列表按**整行**取(`whole_rows`, - // 标定 44/97)⇒ 1180×760 列表 **176px = 4 整行**、面板 ≈307px; + // 标定 44/97)⇒ 1180×760 列表 **176px = 4 整行**、面板 ≈294px; // 900 档 308px = 7 整行。实例名与版本菜单都在面板钉死区(`options::section`)。 (_, PanelState::Options(options)) if options.has_loader_versions() => Self::Shared, _ => Self::Pinned, } } + + /// 列表视口该拿多少高(`None` = 面板按内容高钉死,列表吃剩下的 `Fill`)。 + /// + /// `column_height` 两个分支的入参口径一致:都传 + /// [`frame::body_height`](crate::pages::frame::body_height) 的返回值 + /// (chips 档上方那两行 chip 由 [`narrow_rows`] 自己扣,调用点不用管)。 + pub(crate) fn list_height(self, column_height: f32) -> Option { + match self { + Self::Pinned => None, + Self::Shared => Some(whole_rows(column_height)), + Self::Narrow => Some(narrow_rows(column_height)), + } + } } /// 版本行的**实测节距**(D30 三审:`04` 图行内下载图标的行首 265/309/353/397/441/485, /// 节距恰 **44.0**;首行选中高亮矩形 253–296 也是 44px)。 /// /// 列表是 `Fill + scrollable` 时,视口高度不是节距整数倍就会切在某一行中间——D30 r2 实测 -/// `01` 是 245px(5.3 行),英文态露出 6px 竖干、中文态露出「预览版」上半截。三栏档的 -/// `Shared`(面板先保预算)那一支现在按整行取高([`whole_rows`]),底缘落在行边界上。 +/// `01` 是 245px(5.3 行),英文态露出 6px 竖干、中文态露出「预览版」上半截。`Shared` 与 +/// `Narrow` 两支都按整行取高,底缘落在行边界上。 pub(crate) const ROW_PITCH: f32 = 44.0; -/// 列表以外、列内要扣掉的固定占位(**列顶 156 → 列表顶 253**,D30 三审实测 = 97)。 +/// 三栏档「列表以外、列内要扣掉的固定占位」(**列顶 156 → 列表顶 253**,D30 三审实测 = 97)。 /// /// 1180×760:正文列 580 ⇒ floor((580−97−300) / 44) × 44 = **176px = 4 整行**,分隔线落在 437。 /// 1180×900:floor((720−97−300) / 44) × 44 = **308px = 7 整行**(八审实测分隔线 569)。 /// **改搜索条 / 表头高度时要同步这个数。** pub(crate) const LIST_CHROME: f32 = 97.0; -/// 面板(滚动区 + 钉死区)的**最小预算**。 +/// 三栏档面板(滚动区 + 钉死区)的**最小预算**。 /// /// 滚动区(八审后只剩 head 40 + 六枚 chips 32 + 间距 4 = **76**); -/// 钉死区 = 说明/标签行 17 + 间距 8 + 菜单 64 + 间距 8 + 实例名 56 ≈ **153**。 -/// 取 300 覆盖两者(76 + 153 = 229)再留余量给 chips 底边框与滚动条。 +/// 钉死区 = 说明/标签行 20 + 间距 8 + 菜单 64 + 间距 8 + 实例名 56 = **156**。 +/// 取 300 覆盖两者(76 + 156 = 232)再留余量给 chips 底边框与滚动条。 /// D30 五审实测:上一版「对半分」把滚动区压到 82px——宽档切「安装选项」字身、六枚 chip -/// 0 像素;窄档 24px 全被留白吃掉。 +/// 0 像素;窄档 24px 全被留白吃掉。窄档另有一套更紧的预算,见 [`CHIPS_PANEL_BUDGET`]。 pub(crate) const PANEL_BUDGET: f32 = 300.0; -/// 正文列高度 → 列表视口的**整行高度**(只给三栏档的 Shared 分支用)。 +/// 正文列高度 → 列表视口的**整行高度**(给三栏档的 `Shared` 分支用)。 /// /// 分高规则从「对半分」改成「**面板先保预算,剩下的按整行给列表**」:760 档 /// (正文列 580)⇒ 580 − 97 − 300 = 183 ⇒ floor(183/44)×44 = **176px = 4 整行**; @@ -90,3 +116,85 @@ pub(crate) fn whole_rows(column_height: f32) -> f32 { let room = (column_height - LIST_CHROME - PANEL_BUDGET).max(ROW_PITCH); (room / ROW_PITCH).floor() * ROW_PITCH } + +/// chips 档(可用宽 < 600)里 `rows::content` **上方**那一坨的实测高(M227)。 +/// +/// 分类 tabs 48 + 间距 8 + 主版本 chip 条 48 + 间距 8 + 分隔线 1 + 间距 8 = **121** +/// (720×760 实测:正文列顶 156 → `rows::content` 顶 277)。 +/// **改 `rails::stacked_chips` 的高度时同步这个数**(`Element` 没有几何内省,只能实测 + 常量)。 +pub(crate) const CHIPS_STACK: f32 = 121.0; + +/// chips 档里 `rows::content` 自身的固定占位(M227 实测)。 +/// +/// 列表之前:搜索条 56 + 间距 8 + 分隔线 1 + 间距 8 + 计数头 17 + 间距 8 = 98 +/// (实测内容顶 277 → 列表顶 375);列表之后还要给尾部:间距 8 + 分隔线 1 + 间距 12 = 21 +/// (实测列表底 551 → 分隔线 559 → 面板顶 572)⇒ **119**。 +pub(crate) const CHIPS_LIST_CHROME: f32 = 119.0; + +/// chips 档里选项区**至少要拿到**的高度(M227 的窄档预算)。 +/// +/// 窄档的取舍:钉死区只留实例名,其余全进滚动区(`options::section` 的 `Narrow` 一支)。 +/// 预算 = 上下内边距 12×2 + 首屏内容 104 + 滚动区↔钉死区间距 8 + 实例名 56 = **192**; +/// 其中首屏 104 = head 40 + 间距 4 + 六枚 chip 32 + 间距 8 + 说明/标签行 20。 +/// +/// 为什么菜单在窄档**不钉**(三栏档是钉的):700/720×760 的 `rows::content` 只有 **459**, +/// 若钉死区还要含菜单(说明 20 + 8 + 菜单 64~112 + 8 + 实例名 56 + 内边距 24 = 180~228), +/// 再加首屏 head+chips 76 就是 256~304,列表只剩 ≤1 整行 —— 而 M125 的像素守门 +/// (`tests/options_shot/chips.rs` ②)要求 **≥3 整行 = 132**。两者在 459 里互斥, +/// 所以窄档把菜单放进滚动区(滚得到、滚动条可见),钉死区只留实例名。 +/// +/// 五审实测的坏形态(本预算要消灭的):滚动区视口被钉死区吃成 **0**(五审记的「24px」其实是 +/// 面板内边距 12 + 间距 8 + 说明行顶端),head / 六枚 chip / 说明一个像素都不在屏; +/// 实例名输入框被挤成 **35px**(五审记 36),值墨迹的降部被自己的底边框裁掉。 +#[doc(alias = "窄档预算")] +pub(crate) const CHIPS_PANEL_BUDGET: f32 = 192.0; + +/// 正文列高度 → chips 档列表视口的**整行高度**(M227)。 +/// +/// 720×760:580 − 121([`CHIPS_STACK`])− 119([`CHIPS_LIST_CHROME`])− 192([`CHIPS_PANEL_BUDGET`]) +/// = 148 ⇒ floor(148/44)×44 = **132px = 3 整行**;面板拿走剩下的 208(滚动区 120 ≥ 首屏 104)。 +/// 与 [`whole_rows`] 同一条公式,只是分子换成窄档的三段固定占位。 +#[must_use] +pub(crate) fn narrow_rows(column_height: f32) -> f32 { + let room = + (column_height - CHIPS_STACK - CHIPS_LIST_CHROME - CHIPS_PANEL_BUDGET).max(ROW_PITCH); + (room / ROW_PITCH).floor() * ROW_PITCH +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 窄档预算的算术(M227):760 档恰好 3 整行、面板剩下的高度够首屏 + 实例名。 + /// + /// 抓得住:把预算改小(列表涨到 4 整行 ⇒ 滚动区 < 首屏 104,说明行被切)或改大 + /// (列表掉到 2 整行 ⇒ M125 的像素守门 ② 红)。 + #[test] + fn the_chips_budget_keeps_three_whole_rows_at_760() { + let body = 580.0; // 760 − 标题栏 48 − 页头 84 − 上下页边距 48 + assert_eq!(narrow_rows(body), 132.0, "720×760 的 chips 档列表 = 3 整行"); + let panel = body - CHIPS_STACK - CHIPS_LIST_CHROME - narrow_rows(body); + assert_eq!(panel, 208.0, "面板拿剩下的那块"); + // 滚动区 = 面板 − 上下内边距 24 − 实例名 56 − 滚动区↔钉死区间距 8 + assert!( + panel - 24.0 - 56.0 - 8.0 >= 104.0, + "滚动区 {} < 首屏 104(head 40 + 4 + chips 32 + 8 + 说明 20)", + panel - 24.0 - 56.0 - 8.0 + ); + } + + /// 三栏档那支不动(M227 回归钉子):1180×760 = 176(4 整行)、900 高 = 308(7 整行)。 + #[test] + fn the_three_column_budget_is_unchanged() { + assert_eq!(whole_rows(580.0), 176.0); + assert_eq!(whole_rows(720.0), 308.0); + } + + /// `list_height` 把三档的「列表吃多少」收敛到一处(`Pinned` = 不给定高)。 + #[test] + fn list_height_follows_the_share() { + assert_eq!(TailShare::Pinned.list_height(580.0), None); + assert_eq!(TailShare::Shared.list_height(580.0), Some(176.0)); + assert_eq!(TailShare::Narrow.list_height(580.0), Some(132.0)); + } +} diff --git a/crates/app-iced/src/pages/download/tests/options_shot/chips.rs b/crates/app-iced/src/pages/download/tests/options_shot/chips.rs index 20380e5..333a4ba 100644 --- a/crates/app-iced/src/pages/download/tests/options_shot/chips.rs +++ b/crates/app-iced/src/pages/download/tests/options_shot/chips.rs @@ -7,7 +7,8 @@ use super::{bands, feed, ui, Shot, CHIPS_SHOT}; /// 修前(M123 取证,2026-11-05,tiny-skia,700×760,`ULL_DL_DEMO=options-fabric`): /// chips 档多出两行 chip,选项区又整块钉在列表下面(316px),于是列表列只剩一条 **18px** 的缝, /// 选中行的底色带实测 19px(行高 44)——版本号一个字读不出来,用户换不了版本。 -/// 修法见 `rows::TailShare`:chips 档改成「列表与面板各吃一半,面板自己可滚」。 +/// 修法见 `rows::TailShare`:chips 档改成「面板先保预算、列表按整行吃剩下的」 +/// (M227 起是 `TailShare::Narrow` + `rows/tail.rs::CHIPS_PANEL_BUDGET`,700/720×760 = 3 整行)。 /// /// ```text /// ULL_DL_CHIPS_SHOT=02-options-fabric-en-light-chips700.png \ @@ -15,13 +16,18 @@ use super::{bands, feed, ui, Shot, CHIPS_SHOT}; /// ``` /// /// 抓得住:①首行版本行的底色带 = 44(整行在屏);②**列表区 ≥ 3 整行**(面板上沿那条通栏 -/// 分隔线就是下界);③分隔线下方 96px 内有一条 ≥ 80px 的实色带(面板头那条实心「安装」按钮)—— +/// 分隔线就是下界)——这是窄档预算的硬下限:面板钉死区若重新钉住版本菜单(M227 之前的样子), +/// 它在 459px 里只能再留 1 整行给列表;③面板头部在屏:断言在面板上沿分隔线下方 96px 的窗口 +/// (分隔线 514 ⇒ 窗口 518–610)里找一条 ≥ 80px 的同色带,窄档实测命中 **y=550、宽 94px**, +/// 那是**实心「安装」按钮的药丸本身**——版本菜单容器的上沿在 636–639,落在窗口之外,与本条无关 +/// (r2 复审订正:旧注释把「菜单容器的满宽底边」也列成可能来源,那是错的)。 /// 「能换版本」与「能看见选项面板」是这条断言的成对验收。 /// /// 抓不住:文本有没有被**横向**裁掉(那要看字形宽度,见 `tests/rails.rs` 的标签断言与 /// 报告里的量测表)、hover/tooltip 层(静态图拍不到)、面板内部滚动到底后长什么样 /// (静态图只有一帧,只能拍到 scroll offset 0)——所以这里**不**断言「面板没被窗口底边切」: -/// chips 档的面板本来就是可滚的一半,实例名那一格确实落在折线以下。 +/// chips 档的面板本来自己可滚,实例名钉在面板底部。**M227 起**滚动区首屏装 +/// head + 六枚 chip + 说明行(104px),版本菜单露在折线下方、滚得到。 #[test] fn a_chips_tier_shot_keeps_a_whole_version_row_readable() { let Some(path) = feed(CHIPS_SHOT) else { diff --git a/crates/app-iced/src/pages/download/tests/view.rs b/crates/app-iced/src/pages/download/tests/view.rs index 6c0ffcd..c3a3a7f 100644 --- a/crates/app-iced/src/pages/download/tests/view.rs +++ b/crates/app-iced/src/pages/download/tests/view.rs @@ -146,7 +146,7 @@ fn the_demo_data_is_sane() { assert!(demo.rows.iter().all(|row| !row.supporting.contains('版'))); } -/// 列表与尾部面板怎么分高:**档位 × 面板形态**两个条件同时成立才对半分。 +/// 列表与尾部面板怎么分高:**档位 + 面板形态** → 三档之一(`Pinned` / `Shared` / `Narrow`)。 /// /// 修前(M123):面板一律按内容高钉在底部,700×760 的 chips 档里列表只剩 18px(0.4 行)。 /// M125 r1 修法只判档位,又踩出另一条回归:chips 档「刚进页面还没选版本」(`Hint`,面板只有 @@ -154,12 +154,14 @@ fn the_demo_data_is_sane() { /// (复审 P2-1 实测;main 那一态是 **7 整行**——不是 8:第 8 条墨迹其实是 /// `Pick a version, then install` 那句 hint,横向范围对不上版本号。r2 修回 7 整行)。 /// -/// 现在的判据:`Chips` **且**面板真占高度(`Options` / `Install`)才 `Shared`; -/// `Hint` 在任何档位都 `Pinned`。 +/// 现在的判据(M227 重写,旧文档只写了 M125 那一条条件 —— r8 复审 improve finding): +/// ① `Chips` **且**面板真占高度(`Options` / `Install`)⇒ `Narrow`; +/// ② **任何档位**的 `Options` 只要带加载器版本列表 ⇒ `Shared`(三栏档只走这条); +/// ③ 其余(含 `Hint`)⇒ `Pinned`。 /// -/// 抓得住:①chips 档改回「只判档位」(`Hint` 跟着对半分 ⇒ 列表掉到 4 行,像素守门 -/// `ready::a_ready_shot_keeps_the_whole_visible_list` 立刻红);②三栏档也改成 `Shared` -/// (1180 下选项面板被切成一半,`an_options_shot_…` 会红)。 +/// 抓得住:①chips 档改回「Pinned」(面板把列表压成缝 ⇒ 像素守门 +/// `ready::a_ready_shot_keeps_the_whole_visible_list` / `chips::a_chips_tier_shot_…` 立刻红); +/// ②三栏档也改成 `Narrow`(1180 下钉死区少了菜单与说明,`an_options_shot_…` 的量测会红)。 /// 抓不住:**实际分到多少像素**(`iced` 的 `Element` 没有几何内省,那只能出图量)。 #[test] fn the_chips_tier_shares_the_height_only_when_the_panel_takes_room() { @@ -207,8 +209,8 @@ fn the_chips_tier_shares_the_height_only_when_the_panel_takes_room() { assert_eq!( TailShare::of(VersionRailTier::Chips, &panel_state(&options)), - TailShare::Shared, - "chips 档 + 选项区展开:对半分(列表 4 整行、面板自己可滚)" + TailShare::Narrow, + "chips 档 + 选项区展开:窄档预算(面板只钉实例名,列表 3 整行)" ); assert_eq!( TailShare::of(VersionRailTier::Chips, &panel_state(&hint)),