feat(app-iced): M107 DLVRAIL —— 版本管理页三栏侧栏 + 跨类型搜索
用户令(信息架构):这一页从「下载」改称「版本管理」,版面从「一排 tab + 平铺列表」 改成**页面内三栏**:版本类型栏 + 主版本栏 + 列表/详情,并加搜索框。 - **三栏几何**(`pages/layout/rails.rs`,纯函数 + 数值表):类型栏 140/64、主版本栏 140/64、 间距 24、列表列下限 424(行宽红线)。四档由**可用宽**裁决:Wide ≥888 / Narrow ≥676 / Compact ≥600 / Chips <600,三个门槛都是 `栏宽 + 间距 + 424` 算出来的,不是拍的 - **主版本分组规则**(`download/group.rs`):正式版/预发布按 `major.minor`(`1.21.9-pre3` → `1.21`)、 周更按年桶(`24w14a` → `24w`)、远古版**没有主版本**(`rd-132211`/`a1.2.6` → 第二级只有「全部」); 判据用真实版本样本钉住,不按「第一个点」切 - **跨类型搜索**(`search_bar` + 清空 + 命中数):查询词写进 `AppState.download_query`(页面无状态, 只能这么活),`hits` 是**跨分类**读数;三条口径分开:「有命中」/「没有匹配」/「命中在别的类型里」 - 两级侧栏都用 `navigation::list_item` / `tabs` / `chip` / `buttons::icon`:Tab 可达、hover/pressed/ 选中态全部来自组件,页面不自己写状态层 - 右栏展开(M104 `content_width` 852)⇒ 可用宽 716 ⇒ Narrow 档,列表 464(任务书那句 492 漏扣 96px 的页边距与栏间距,报告里给了完整数值表) - 文案 11 条中英进 `crates/i18n`(新域宏接在装配链末尾 ⇒ `Key::ALL` 只追加,金表只追加) - 护栏:两层侧栏都存在 + 列表按第二级选中态过滤(变异可证)、三档门槛、424 下限扫描、 版本行单行宽账、搜索条真的装着 - `install.rs` 只动了「已装」分支(加一句「已安装」解释禁用按钮),没碰 M105 的选项区 取证:`DevDocs/artifacts/m107/`(10 张:三档 × 浅深 + 搜索命中 + 空 + 异地 + 远古版)
This commit is contained in:
1 parent
72b0c9d465
commit
89a51a63b1
24 files changed
+2000
-340
No files matched your search
@@ -0,0 +1,320 @@
|
||||
//! 版本管理页的**三栏装配**:类型栏 + 主版本栏 + 列表列(宽档不够时退回顶部 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::{column, container, row, scrollable};
|
||||
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;
|
||||
|
||||
/// 类型栏里一项的图标。
|
||||
///
|
||||
/// 图标语汇只有 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))
|
||||
.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`
|
||||
container(buttons::icon(
|
||||
ui,
|
||||
select_variant(selected),
|
||||
category_icon(category),
|
||||
Some(press),
|
||||
None,
|
||||
))
|
||||
.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()
|
||||
}
|
||||
|
||||
/// 选中态 → 图标按钮的三式(`Tonal` = 当前项,与 ListItem 的 `SecondaryContainer` 同色系)。
|
||||
fn select_variant(selected: bool) -> IconVariant {
|
||||
if selected {
|
||||
IconVariant::Tonal
|
||||
} else {
|
||||
IconVariant::Standard
|
||||
}
|
||||
}
|
||||
|
||||
/// 折叠 / 展开类型栏的图标按钮(`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,
|
||||
)
|
||||
}
|
||||
|
||||
/// 栏标题(`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()
|
||||
}
|
||||
|
||||
/// 第二栏:主版本桶(第一项永远是「全部版本」;没有主版本概念的版本不单独占一项)。
|
||||
fn major_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);
|
||||
if !icon_only {
|
||||
body = body.push(rail_title(
|
||||
ui,
|
||||
catalog.text(Key::VersionMajorTitle),
|
||||
frame::gap(ui.metrics().nav_item_gap),
|
||||
));
|
||||
}
|
||||
|
||||
body = body.push(major_item(
|
||||
ui,
|
||||
catalog.text(Key::VersionMajorAll),
|
||||
Message::SelectMajor(None),
|
||||
model.selected_major.is_none(),
|
||||
));
|
||||
|
||||
for bucket in &model.buckets {
|
||||
// `None` 桶 = 远古版那类没有主版本的版本:第二级只有一个「全部」,不画空桶
|
||||
let Some(key) = bucket.key.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
body = body.push(major_item(
|
||||
ui,
|
||||
key,
|
||||
Message::SelectMajor(Some(key.to_string())),
|
||||
model.selected_major.as_deref() == Some(key),
|
||||
));
|
||||
}
|
||||
|
||||
body.into()
|
||||
}
|
||||
|
||||
/// 主版本的一项:`ListItem` 单行(64 宽里 `1.21` 仍完整——`BodyLarge` 4 字符 ≈ 32px ≤ 64−32 内边距)。
|
||||
fn major_item<'a>(
|
||||
ui: &'a Ui,
|
||||
label: &'a str,
|
||||
press: Message,
|
||||
selected: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let mut item = ListItem::new(label, press);
|
||||
if selected {
|
||||
item = item.selected();
|
||||
}
|
||||
list_item(ui, item)
|
||||
}
|
||||
|
||||
/// 顶部 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);
|
||||
}
|
||||
|
||||
let mut majors = row![]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center);
|
||||
majors = majors.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::VersionMajorAll),
|
||||
model.selected_major.is_none(),
|
||||
Message::SelectMajor(None),
|
||||
),
|
||||
));
|
||||
for bucket in &model.buckets {
|
||||
let Some(key) = bucket.key.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
majors = majors.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
key,
|
||||
model.selected_major.as_deref() == Some(key),
|
||||
Message::SelectMajor(Some(key.to_string())),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
column![tabs(ui, types), majors]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
Reference in new issue
Block a user