Files
Unlucky-Launcher/crates/app-iced/src/pages/download/rails.rs
T
AskaEth 0f26ed3753 fix(app-iced): M125 r2 —— chips 档「还没选版本」不再对半切;宽度表换成实测值
r1 复审 p2-1:`TailShare::of` 只判档位,于是 chips 档「刚进页面」(面板只有一行
提示)也被五五分——700×760 下列表 8 个版本只显示 4 个、下半块 ≈160px 空画布,
而修前(main)这一态是 7 整行。修法:判据加**面板形态**,`Hint` 在任何档位都钉死,
`Chips` + `Options`/`Install` 才共享。实测回到 7 整行(与 main 同一水位)。

守门:新增 `options_shot/ready.rs`(`ULL_DL_READY_SHOT`,不设 `ULL_DL_DEMO` 出的
700×760 图):列表区至少读出 6 个版本号。它在 r1 的图上**红**(4 个,原生输出见
mission 报告),在修后图上绿(7 个)。三条旧守门照旧(chips / compact / wide),
chips 与 compact 换修前图仍红。

r1 复审 p2-2:宽度表的实测值改正(同一张 736 图逐项量):
`1.21` 26(余 6)、`1.20` 28(余 4)、`24w` **29**(余 3)、`All versions` 墨迹画到
x=247(裁切边 248,只差 1px)⇒ 桶标签余量是 **3~6px** 而不是 6;「短标签『全部』= 32
零余量」这条论证的对比强度随之写准(比桶标签的 3~6px 更薄)。
`rails/major.rs` 的表格、`options_shot/compact.rs` 的测试文档一并改成实测值;
「墨迹右边界」那条反例也从 216..244 改成实测 216..247。

r1 复审 p3-1:第二级「全部版本」的图标从网格(与第一级同字形同药丸、像素级同形)
换成文档/清单字形 `IconKind::Logs`,理由与「图标集里没有版本/历史字形」写进
`major_shape` 文档。

r1 复审 p3-2:chips 档最多三层滚动(列表 / 面板 / loader 菜单)作为**已知风险**
登记在 `install.rs` 文档里,并写明为什么「把实例名移出滚动区」本轮不做(要拆
`options::section`,动三个文件,而且改完仍无法验证滚轮冒泡——没有通道)。
2026-11-05 16:43:35 +08:00

310 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,
) -> 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)),
))
.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()
}