//! 列表列:搜索条 + 计数头 + **单行**版本行 + 底部安装面板。 //! //! ## 这一列的职责边界 //! //! | 位置 | 画什么 | 消息 | //! |---|---|---| //! | 搜索条 | 真实控件的 `search_bar`(56 高、药丸)+ 清空按钮 + 命中数 | [`Message::SetVersionQuery`] / [`Message::ClearVersionQuery`] | //! | 计数头 | `{count} versions`(`LabelMedium` + `OnSurfaceVariant`,与实例页列表头同一个语言) | 无 | //! | 列表 | `ListItem` + 三列短文本 + 尾随图标按钮 | [`Message::SelectVersion`] / [`Message::InstallVersion`] | //! | 面板 | 见 [`super::install`](M105 的安装选项区**不在这里**) | — | //! //! ## 版本行为什么必须是**单行** //! //! 任务硬约束:列表列最窄 424(行宽下限),此时「版本号 + 类型 + 日期 + 安装按钮」四样仍要在 //! 一行里。所以:版本号那一格是 `ListItem`(它自己的 `Wrapping::None` 保证不折行,超长由外层 //! clip 切掉),类型 / 日期 / 安装按钮三格是**定宽**([`super::super::layout::rails`] 的音阶)。 //! 折行的唯一可能来源是定宽格子挤爆容器——那条由列表列下限 424 挡住(`layout/rails/tests.rs` //! 的扫描断言)。 //! //! ## 搜索的三条口径 //! //! | 情形 | 画什么 | 为什么 | //! |---|---|---| //! | 有命中 | 列表照常,侧栏**位置不动**,搜索条右侧显示「N 个匹配」 | 命中数是「我搜的词有货」的读数,不是分页读数 | //! | 一个都不命中(跨类型也没有) | [`super::states::no_match`]:一句「没有匹配」+ 一句「换短一点的片段」 | 白屏会让人以为列表坏了 | //! | 有命中但都不在当前类型 | [`super::states::elsewhere`]:说清「有 N 个在别的类型里」+ 一个「看全部类型」按钮 | 否则用户会以为自己搜错了 | //! //! ## 行距归零(M102,与本页的单行版本行同时成立) //! //! M102 把列表行距归零:原先页面在 `ListItem` 的可视高度之外又加了 `Space::X1`(4),行节奏 //! 与可视高度除不尽,实例页复现成「末行被卡片底边切半行」(视觉复审 ②)。规则:行距只由 //! 组件自己的几何决定,`Space` 里也没有 0 这一档 ⇒ `list()` 里**不写** `.spacing(…)`。 //! 本页的行是 44 的单行档,同样不加行距;[`ROW_GAP`] 只用在**行内**那三个格子的间距上。 use iced::widget::{column, container, row, scrollable}; use iced::{Alignment, Element, Length, Padding}; use unlucky_design::color::Role; 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::divider; use crate::app::components::inputs::{search_bar, SearchBar}; use crate::app::components::navigation::list_item::{list_item, ListItem}; use crate::theme::Ui; use super::super::frame; use super::super::layout::rails::{LIST_DATE_WIDTH, LIST_KIND_WIDTH, ROW_GAP, ROW_ICON_BUTTON}; use super::super::Message; use super::model::{Download, VersionRow}; use super::{install, states}; /// 列表列(三栏档与 chips 档共用一份)。 pub(super) fn content<'a>( ui: &'a Ui, catalog: Catalog, model: &'a Download, ) -> Element<'a, Message> { let visible = visible(model); let body: Element<'a, Message> = if model.no_match() { states::no_match(ui, catalog) } else if model.only_elsewhere() { states::elsewhere(ui, catalog, model.hits) } else if visible.is_empty() { states::empty(ui, catalog) } else { list(ui, catalog, model, &visible) }; let tail: Element<'a, Message> = if model.install.is_some() { column![divider::full(ui), install::panel(ui, catalog, model)] .spacing(ui.space(Space::X3)) .width(Length::Fill) .into() } else { install::panel(ui, catalog, model) }; column![ search_row(ui, catalog, model), divider::full(ui), head(ui, catalog, visible.len()), body, tail, ] .spacing(ui.space(Space::X2)) .width(Length::Fill) .height(Length::Fill) .into() } /// 搜索条 + 清空按钮 + 命中数(三者一行;命中数只在有查询词时出现)。 pub(super) fn search_row<'a>( ui: &'a Ui, catalog: Catalog, model: &'a Download, ) -> Element<'a, Message> { let bar = search_bar( ui, SearchBar::new(model.query.as_str()) .placeholder(catalog.text(Key::VersionSearchPlaceholder)), Message::SetVersionQuery, ); let mut line = row![container(bar).width(Length::Fill)] .spacing(ui.space(Space::X2)) .align_y(Alignment::Center) .width(Length::Fill); if model.has_query() { // 命中数是**跨类型**的读数(`Download::hits`),所以它在「当前分类没命中」时也照画 line = line.push(frame::text_role_owned( ui, TypeRole::LabelMedium, false, catalog.render( Key::VersionSearchHits, &[("count", &model.hits.to_string())], ), ui.color(Role::OnSurfaceVariant), )); line = line.push(buttons::icon( ui, IconVariant::Standard, IconKind::Close, Some(Message::ClearVersionQuery), None, )); } line.into() } /// 计数头(`LabelMedium`):一行 = 一个 key,数字走占位符,不拼片段。 pub(super) fn head<'a>(ui: &'a Ui, catalog: Catalog, count: usize) -> Element<'a, Message> { frame::text_role_owned( ui, TypeRole::LabelMedium, false, catalog.render(Key::DownloadCount, &[("count", &count.to_string())]), ui.color(Role::OnSurfaceVariant), ) } /// 当前可见的行:先按分类(模型已过滤),再按第二级侧栏选中的主版本桶。 fn visible(model: &Download) -> Vec<&VersionRow> { match model.selected_major.as_deref() { None => model.rows.iter().collect(), Some(key) => model .rows .iter() .filter(|entry| entry.major().as_deref() == Some(key)) .collect(), } } /// 版本行列表(**独立滚动**:只有列表滚,安装面板钉在底部不跟着滚)。 fn list<'a>( ui: &'a Ui, catalog: Catalog, model: &'a Download, visible: &[&'a VersionRow], ) -> Element<'a, Message> { let entries = column( visible .iter() .map(|entry| version_row(ui, catalog, model, entry)) .collect::>(), ) // M102:行距归零(见模块文档),只留横向留白 .padding(Padding::from([0.0, ui.space(Space::X2)])) .width(Length::Fill); scrollable(entries) .direction(scrollable::Direction::Vertical( scrollable::Scrollbar::new() .width(frame::SCROLLBAR_WIDTH) .scroller_width(frame::SCROLLBAR_WIDTH) .margin(frame::SCROLLBAR_MARGIN), )) .height(Length::Fill) .style(frame::scroll_style(ui)) .into() } /// 一行:`ListItem`(版本号,可点选)+ 类型 + 日期 + 安装图标按钮(**四样同一行**)。 /// /// 点版本号 = [`Message::SelectVersion`](页面内选中,不 dispatch);点尾随按钮 = /// [`Message::InstallVersion`]。已装的版本给勾 + 禁用按钮(不重复安装)。 fn version_row<'a>( ui: &'a Ui, catalog: Catalog, model: &Download, entry: &'a VersionRow, ) -> Element<'a, Message> { let mut item = ListItem::new(entry.id.as_str(), Message::SelectVersion(entry.id.clone())) .leading(IconKind::Download); if entry.installed { item = item.trailing(IconKind::Check); } if model.selected.as_deref() == Some(entry.id.as_str()) { item = item.selected(); } // 版本号那一格吃掉剩下的宽(`ListItem` 自己是 `Wrapping::None`:再长也**不折行**, // 由外层 clip 切掉)。定宽的三格因此永远落在右边,行宽下限 424 保证它们不会被挤出容器。 row![ container(list_item(ui, item)).width(Length::Fill), cell( ui, catalog.text(super::model::kind_label_key(entry.kind)), LIST_KIND_WIDTH ), cell(ui, entry.released.as_str(), LIST_DATE_WIDTH), install_action(ui, entry), ] .spacing(ui.space(ROW_GAP)) .align_y(Alignment::Center) .width(Length::Fill) .into() } /// 一格定宽文本(`LabelMedium` + `OnSurfaceVariant`;单行,超长由外层 clip)。 fn cell<'a>(ui: &'a Ui, text: &'a str, width: f32) -> Element<'a, Message> { container(frame::text_role( ui, TypeRole::LabelMedium, false, text, ui.color(Role::OnSurfaceVariant), )) .width(Length::Fixed(width)) .into() } /// 行尾的安装按钮:已装 → `Check`(`Primary`);未装 → `Download` 图标按钮(命中区 40/48)。 /// /// 「本页唯一实心 accent」留在底部面板那个带文字的「安装」按钮上(rubric K2),行内用标准图标按钮: /// 一行一个实心药丸会让列表变成一排强调色。 fn install_action<'a>(ui: &'a Ui, entry: &'a VersionRow) -> Element<'a, Message> { if entry.installed { return container(frame::glyph( IconKind::Check, ui.color(Role::Primary), frame::ICON_PX, )) .width(Length::Fixed(ROW_ICON_BUTTON)) .align_x(Alignment::Center) .into(); } container(buttons::icon( ui, IconVariant::Standard, IconKind::Download, Some(Message::InstallVersion(entry.id.clone())), None, )) .width(Length::Fixed(ROW_ICON_BUTTON)) .align_x(Alignment::Center) .into() }