fix(app-iced): M107 r1 复审五条 p2 —— 搜索与主版本桶一致、chips 可滚、单复数、端到端

复审 r1(p2-5items / fix-then-merge)的五条可达问题,逐条落点:

- **P2-1** 搜索命中数与主版本桶不再自相矛盾:`group::active_bucket` 让第二级在搜索期间
  **自动放宽到「全部」**(选择本身留在模型里,清空搜索后回来),侧栏点亮「全部版本」⇒ 没有幽灵选中;
  `rebuild_after_query` 回填时校验桶仍存在(新桶列表里没有的选择直接丢弃)。
  判据:`a_search_hit_never_lands_on_an_empty_list`(4 分类 × 5 桶值 × 4 查询词的矩阵)+ 出图 11
- **P2-2** chips 档的主版本行套**横向滚动**(显式滚动条),竖栏与 chips 吃同一份 `major_entries`;
  22 桶夹具断言最后一个桶 `1.0` 在列表里,出图 12 看得到滚动条
- **P2-3** 只做搜索这条路径(`rebuild_after_query` + 校验 + app 层测试);`app/state.rs::refresh_view`
  的全量修法**留到 M105 之后**(不在 scope,且 M105 正改同一片文件)——报告里明写
- **P2-4** `1 matches` 修掉:新增 `VersionSearchHitsOne` / `VersionSearchElsewhereOne` 两条单数 key,
  「在别处」用独立标题(不再拿命中数当标题,同屏一个数只报一次)
- **P2-5** 搜索链路补 4 条 **app 层端到端**(键入 → 快照 → 重建 → 列表);变异自证:
  删 `self.state.download_query = query` ⇒ 2 条红;`from_snapshot` 忽略 query ⇒ 8 条红;
  去掉 `active_bucket` 的放宽 ⇒ 4 条红
- 顺带:64 宽图标条每项挂 tooltip(复审建议);`install.rs` 那段自相矛盾的文档订正

门禁:fmt 0 / app-iced lib 431 passed / workspace --exclude unlucky-app 全绿(见报告)
This commit is contained in:
AskaEth committed 2026-11-05 02:01:11 +08:00
1 parent 89a51a63b1
commit 45544791e5
11 files changed
+622 -56

No files matched your search

+99 -44
View File
@@ -24,7 +24,8 @@
//! `buttons::icon`):焦点由 iced 的焦点系统给,Tab 依次走「类型栏 → 主版本栏 → 搜索框 → 版本行 →
//! 安装」,Enter 激活。页面不自己写焦点环(那是组件的状态层)。
use iced::widget::{column, container, row, scrollable};
use iced::widget::tooltip::Position;
use iced::widget::{column, container, row, scrollable, tooltip};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
@@ -45,7 +46,7 @@ use super::super::frame;
use super::super::layout::{VersionRails, RAIL_ICON_WIDTH};
use super::super::Message;
use super::model::{Category, Download};
use super::rows;
use super::{group, rows};
/// 类型栏里一项的图标。
///
@@ -135,17 +136,13 @@ fn type_rail<'a>(
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()
// 图标条: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 {
@@ -158,6 +155,32 @@ fn type_rail<'a>(
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,
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))),
Position::Right,
)
.into()
}
/// 选中态 → 图标按钮的三式(`Tonal` = 当前项,与 ListItem 的 `SecondaryContainer` 同色系)。
fn select_variant(selected: bool) -> IconVariant {
if selected {
@@ -231,37 +254,63 @@ fn major_rail<'a>(
));
}
body = body.push(major_item(
ui,
catalog.text(Key::VersionMajorAll),
Message::SelectMajor(None),
model.selected_major.is_none(),
));
for entry in major_entries(catalog, model) {
body = body.push(major_item(ui, entry.label, entry.key, entry.selected));
}
body.into()
}
/// 第二级侧栏的一项(「全部版本」与每个主版本桶共用一套结构,好让**两条渲染路径**
/// ——竖栏与 chips 行 —— 都用同一份数据,测试也只钉这一份)。
///
/// 字段全部**借**模型里的桶标签与目录里的静态串:这两条渲染路径都要把标签塞进 iced 控件树,
/// 借本地的 `String` 会让整棵树的生命周期塌到函数作用域里(E0515)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct MajorEntry<'a> {
/// 选中它要发的键(`None` = 「全部版本」)
pub key: Option<&'a str>,
/// 画出来的标签
pub label: &'a str,
/// 此刻是否选中(**按生效值算**:搜索期间恒为「全部」,见 `group::bucket_selected`)
pub selected: bool,
}
/// 第二级侧栏要画的全部项:`[全部版本, 1.21, 1.20, …]`,顺序 = 桶的版本序。
///
/// 抽成纯函数是为了让「最后一个桶可点」这件事**可断言**(复审 P2-2 的要求):
/// 竖栏与 chips 行都从这一份数据画,chips 行外面还套了横向滚动,所以「画得出来」= 「够得着」。
/// 返回 `Vec` 而不是迭代器:调用方两处都要 len(chips 的宽度预算)与随机访问。
pub(super) fn major_entries<'a>(catalog: Catalog, model: &'a Download) -> Vec<MajorEntry<'a>> {
let mut entries = vec![MajorEntry {
key: None,
label: catalog.text(Key::VersionMajorAll),
selected: group::bucket_selected(model, 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),
));
entries.push(MajorEntry {
key: Some(key),
label: key,
selected: group::bucket_selected(model, Some(key)),
});
}
body.into()
entries
}
/// 主版本的一项:`ListItem` 单行(64 宽里 `1.21` 仍完整——`BodyLarge` 4 字符 ≈ 32px ≤ 64−32 内边距)。
fn major_item<'a>(
ui: &'a Ui,
label: &'a str,
press: Message,
key: Option<&str>,
selected: bool,
) -> Element<'a, Message> {
let mut item = ListItem::new(label, press);
let mut item = ListItem::new(label, Message::SelectMajor(key.map(str::to_string)));
if selected {
item = item.selected();
}
@@ -288,32 +337,38 @@ pub(super) fn stacked_chips<'a>(
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);
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;
};
for entry in major_entries(catalog, model) {
majors = majors.push(chip(
ui,
Chip::filter(
key,
model.selected_major.as_deref() == Some(key),
Message::SelectMajor(Some(key.to_string())),
entry.label,
entry.selected,
Message::SelectMajor(entry.key.map(str::to_string)),
),
));
}
column![tabs(ui, types), majors]
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()