Files
Unlucky-Launcher/crates/app-iced/src/pages/download/rails.rs
T
AskaEth 954281d71a fix(app-iced): 版本列表在三栏对半分档按整行取高(S1b/S9)
列表视口高度不再是「Fill 剩下的」:正文列高度由仓里已有的 `frame::body_height` 算出,
经 `view_sized` → `body` → `rails::three_column` → `rows::content` 传下来,Shared 档据此
`floor(((h − LIST_CHROME)/2) / ROW_PITCH) * ROW_PITCH` 取整行。

实测(1180×760):列表 245px → **239px**(分隔线 498 → 492,腾出的 6px 归面板),
`04` 对照**未变**(531 / 278px),720 窄窗**未变**(本来就是 4.0 整行、底缘 0 墨迹)。

两处修正写在提交里:
1. 第一版给所有三栏场合取整,把 `04` 也缩到 239px ⇒ 收窄为只在 `TailShare::Shared` 取整;
2. `rows.rs` 涨到 419 行撞 400 行门禁 ⇒ `TailShare` 与三个标定量纯搬移到 `rows/tail.rs`,
   `rows.rs` 回到 355 行(门禁 `components::gate` 与全仓扫描都绿)。

`view` 保留 4 参数(`#[cfg(test)]`,只有单测用),产品路径走 `view_sized`。
2026-11-10 19:34:13 +08:00

312 lines
12 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.
//! 版本管理页的**三栏装配**:类型栏 + 主版本栏 + 列表列(宽档不够时退回顶部 chips)。
//!
//! ## 三栏各自的内容
//!
//! | 栏 | 画什么 | 交互 |
//! |---|---|---|
//! | 版本类型(第一栏) | 折叠开关 + 四个类型项(原顶部 tabs 的四个 chip,语义一字不改) | [`Message::SetCategory`](真指令:`AppCommand::ListVersions`) |
//! | 主版本(第二栏) | 「全部版本」+ 快照里出现的每个主版本桶([`super::group`]) | [`Message::SelectMajor`](纯视图态) |
//! | 列表 / 内容(第三栏) | 搜索条 + 命中数 + 计数头 + 版本行 + 底部安装面板 | [`Message::SetVersionQuery`] / 选版本 / 安装 |
//!
//! ## 宽度不在这里拍
//!
//! 两条栏的宽度来自 [`VersionRails`](宽档裁决,见 `pages/layout/rails.rs` 的数值表)。
//! 本文件只做两件事:把裁决出来的数字用上、把「用户手动折叠类型栏」这条覆盖叠上去。
//!
//! ## 为什么搜索条在第三栏而不是横跨三栏
//!
//! 搜索过滤的对象是**第三栏的列表**;横跨三栏会让人以为它也在搜侧栏里的类型。放进列表列顶部,
//! 「输入 → 下面的行变少」这条因果在视觉上就是同一条竖线;命中数是这条因果的读数,紧贴右侧。
//!
//! ## 键盘可达
//!
//! 两层侧栏的每一项都是真组件按钮(`navigation::list_item` / `navigation::tabs` /
//! `buttons::icon`):焦点由 iced 的焦点系统给,Tab 依次走「类型栏 → 主版本栏 → 搜索框 → 版本行 →
//! 安装」,Enter 激活。页面不自己写焦点环(那是组件的状态层)。
use iced::widget::tooltip::Position;
use iced::widget::{column, container, row, scrollable, tooltip};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
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, IconVariant};
use crate::app::components::navigation::list_item::{list_item, ListItem};
use crate::app::components::navigation::tabs::{tabs, Tab, Tabs};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::super::frame;
use super::super::layout::{VersionRails, RAIL_ICON_WIDTH};
use super::super::Message;
use super::model::{Category, Download};
use super::rows;
pub(super) mod major;
pub(super) use major::{major_entries, major_rail};
// 只有测试要从这里拿「一项画成什么样」的判定(lib 里由 `major_item` 内部消费)
#[cfg(test)]
pub(super) use major::{major_shape, MajorShape};
/// 类型栏里一项的图标。
///
/// 图标语汇只有 28 个描边字形(没有 `History` / `Camera`),所以借用:
/// **全部 = 网格、正式版 = 勾、预览版 = 刷新、远古版 = 档案袋**。已登记为偏离。
pub(super) const fn category_icon(category: Category) -> IconKind {
match category {
Category::All => IconKind::Instances,
Category::Release => IconKind::Check,
Category::Snapshot => IconKind::Refresh,
Category::Ancient => IconKind::Folder,
}
}
/// 三栏装配(`Wide` / `Narrow` / `Compact` 三档共用)。
pub(super) fn three_column<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
rails: VersionRails,
body_height: f32,
) -> Element<'a, Message> {
row![
panel(
ui,
rails.type_rail,
type_rail(ui, catalog, model, rails.type_rail)
),
panel(
ui,
rails.major_rail,
major_rail(ui, catalog, model, rails.major_rail)
),
container(rows::content(
ui,
catalog,
model,
rows::TailShare::of(rails.tier, &super::install::panel_state(model)),
Some(rows::whole_rows(body_height)),
))
.width(Length::Fixed(rails.list))
.height(Length::Fill),
]
.spacing(super::super::layout::rails::RAIL_GAP)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
/// 一条侧栏的外壳:固定宽 + 面板底色 + 独立滚动(栏内内容不横向挤)。
fn panel<'a>(
ui: &'a Ui,
width: f32,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
container(scrollable(content).height(Length::Fill).style(frame::scroll_style(ui)))
.width(Length::Fixed(width))
.height(Length::Fill)
// 水平不留内边距:140 宽里 `ListItem` 自己的 16+16 已经够,再多 8 会把 `Snapshot` 切掉
// (首轮出图实测「Snapsh」)。垂直留 8 让第一项不贴顶。
//
// 底色取 `Card` 层(`SurfaceContainerLow`,比页面 Canvas 高一档):首轮出图用的是 Canvas,
// 两条栏和页面底色一模一样 ⇒ 读起来像「一堆文字浮在左边」,不像两条侧栏。
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.style(move |_theme| frame::filled(ui.layer(MaterialLayer::Card)))
.into()
}
/// 第一栏:版本类型(`140` 展开 / `64` 图标条)。
fn type_rail<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
width: f32,
) -> Element<'a, Message> {
// 宽度 ≤ 图标条宽 ⇒ 只画图标。用户手动折叠与窄档折叠走同一条判据(不另设开关)。
let icon_only = width <= RAIL_ICON_WIDTH;
let mut body = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
let fold = fold_action(ui, model, icon_only);
if icon_only {
body = body.push(
container(fold)
.width(Length::Fill)
.align_x(Alignment::Center),
);
} else {
body = body.push(rail_title(ui, catalog.text(Key::VersionRailTitle), fold));
}
for category in Category::ALL {
let selected = category == model.category;
let label = catalog.text(category.label_key());
let press = Message::SetCategory(category);
body = body.push(if icon_only {
// 图标条:40×40 图标按钮(命中区 48 由栏内留白补足),选中 = `Tonal`。
// 64 宽放不下标签,所以每一项都挂 tooltip(复审建议):四个图标既不说话也没有标题时,
// 用户只能靠猜。tooltip 的容器色/圆角走令牌,不新造样式。
container(labeled(ui, label, category_icon(category), selected, press))
.width(Length::Fill)
.align_x(Alignment::Center)
.into()
} else {
let mut item = ListItem::new(label, press).leading(category_icon(category));
if selected {
item = item.selected();
}
list_item(ui, item)
});
}
body.into()
}
/// 带 tooltip 的图标项(图标条用)。tooltip 位置在右侧——它是竖栏,右边就是内容区,
/// 弹在下面会盖住下一项。
fn labeled<'a>(
ui: &'a Ui,
label: &'a str,
kind: IconKind,
selected: bool,
press: Message,
) -> Element<'a, Message> {
let button = buttons::icon(ui, select_variant(selected), kind, Some(press), None);
tooltip(button, tip_body(ui, label), Position::Right).into()
}
/// tooltip 的正文盒子(`BodySmall` + `OnSurface` 叠 `Overlay` 层):类型栏的图标项与
/// 主版本栏图标条档的「全部版本」共用一份文字语言。
fn tip_body<'a>(ui: &'a Ui, label: &'a str) -> Element<'a, Message> {
container(frame::text_role(
ui,
TypeRole::BodySmall,
false,
label,
ui.color(Role::OnSurface),
))
.padding(ui.space(Space::X2))
.style(move |_theme| frame::filled(ui.layer(MaterialLayer::Overlay)))
.into()
}
/// 选中态 → 图标按钮的三式(`Tonal` = 当前项,与 ListItem 的 `SecondaryContainer` 同色系)。
fn select_variant(selected: bool) -> IconVariant {
if selected {
IconVariant::Tonal
} else {
IconVariant::Standard
}
}
/// 栏标题(`LabelMedium` + `OnSurfaceVariant`,与列表计数头同一档字级)。
///
/// 高度固定成 `control_height`(40 = 图标按钮的边长):两条栏的标题必须**横向对齐**,
/// 而第一栏标题右侧有个 40 高的折叠按钮、第二栏没有。不固定高度的话两行标题会差 12px。
fn rail_title<'a>(
ui: &'a Ui,
title: &'a str,
action: Element<'a, Message>,
) -> Element<'a, Message> {
container(
row![
container(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
title,
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill),
action,
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([0.0, ui.space(Space::X2)])),
)
.height(Length::Fixed(ui.metrics().control_height))
.align_y(Alignment::Center)
.into()
}
/// 折叠 / 展开类型栏的图标按钮(`Sidebar` 字形;折叠中 = `Tonal`)。
fn fold_action<'a>(ui: &'a Ui, model: &Download, icon_only: bool) -> Element<'a, Message> {
// 已经被宽度压成图标条时不再给开关:那时点它也不会变宽(档位说了算),
// 一个按了没反应的按钮正是 D30 判 p1 的那种假控件。
if icon_only && !model.type_rail_folded {
return frame::gap(ui.metrics().nav_item_gap);
}
buttons::icon(
ui,
select_variant(model.type_rail_folded),
IconKind::Sidebar,
Some(Message::ToggleTypeRail),
None,
)
}
/// 顶部 chips(可用宽 < 600 时三栏不成立:类型退回 Primary tabs,主版本变成 Secondary chips 行)。
///
/// 与三栏档**同源**:同一批 [`Category`] / 同一批主版本桶 / 同两条消息,不是另造一套筛选状态。
pub(super) fn stacked_chips<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
) -> Element<'a, Message> {
let mut types = Tabs::primary();
for category in Category::ALL {
let mut tab = Tab::new(
catalog.text(category.label_key()),
Message::SetCategory(category),
);
if category == model.category {
tab = tab.selected();
}
types = types.tab(tab);
}
// 主版本 chips:**从同一份 `major_entries` 画**,并且整行套横向滚动(复审 P2-2)。
//
// 为什么必须能滚:真实清单里 `Release` 就有 ~20 个桶(1.21…1.7),chips 档的可用宽不到 600、
// 一行只放得下 5~6 个。没有横向滚动时后面的桶**永远点不到**——而这正是「主版本下拉」要解决的
// 那件事(设计系统里没有 dropdown 组件,所以用可横向滚动的 chips 行承担同一职责)。
let mut majors = row![]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
for entry in major_entries(catalog, model) {
majors = majors.push(chip(
ui,
Chip::filter(
entry.label,
entry.selected,
Message::SelectMajor(entry.key.map(str::to_string)),
),
));
}
let strip = scrollable(majors)
.direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fixed(
ui.metrics().control_height + ui.space(Space::X2),
))
.style(frame::scroll_style(ui));
column![tabs(ui, types), strip]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into()
}