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:
AskaEth committed 2026-11-05 01:59:13 +08:00
1 parent 72b0c9d465
commit 89a51a63b1
24 files changed
+2000 -340

No files matched your search

+227
View File
@@ -0,0 +1,227 @@
//! 版本管理页的**三栏几何**:版本类型栏 + 主版本栏 + 列表列。纯函数,不碰 iced 控件树。
//!
//! 这一页是 `PaneKit::Single` 之外的第二种「页内分栏」:外层仍是单列(页头铺满页宽),
//! 正文自己切成三栏。所以它不能复用 [`super::PaneKit`](那是**页面级**版面决议),
//! 而是在可用宽上再做一次**页内**裁决 —— 本文件就是那次裁决的唯一真源。
//!
//! ## 1. 两个侧栏的宽度
//!
//! | 栏 | 展开 | 收成图标条 | 依据 |
//! |---|---|---|---|
//! | 版本类型栏 | **140** | **64** | 140 = `12b §7` drawer 项「图标 24 + 间距 12 + 8 字符标签」;`Snapshot` 是最长标签(8 字符 ≈ 62px),24+12+62+2×16 内边距 = 130,取 4px 栅格 **140** |
//! | 主版本栏 | **140** | **64** | 同一档宽度:主版本是 `1.21` 这种 3~5 字符的短串,140 里放得下「主版本 + 该组版本数」两行;64 里只放得下版本号本身(40 可用宽够 4 字符 `LabelMedium`) |
//!
//! 64 = `LayoutMetrics::nav_rail_width` 的一半再对齐栅格?**不是**:64 是 4px 栅格上的
//! 「图标 24 + 两侧内边距 2×16 + 余量 8」,比导航轨 88 窄一档(导航轨要放 56 宽的选中药丸,
//! 页内侧栏只要 40 宽的选中条)。
//!
//! ## 2. 四档与各自的门槛(门槛都是**算出来的**)
//!
//! 记 `A` = **页面块宽**([`super::block::body_width`](super::block::body_width) = 可用宽
//! 封顶 1440,M102 的口径;≤1440 时它就是可用宽,所以下面这张表在 1180/1024/900/700 上不变)。
//!
//! | 档 | 门槛 | 类型栏 | 主版本栏 | 列表列 |
//! |---|---|---|---|---|
//! | `Wide` | `A ≥ 888` | 140 | 140 | `A − 328` |
//! | `Narrow` | `676 ≤ A < 888` | **64** | 140 | `A − 252` |
//! | `Compact` | `600 ≤ A < 676` | 64 | **64** | `A − 176` |
//! | `Chips` | `A < 600` | 0 | 0 | `A` |
//!
//! 三个门槛的来源(`L` = 列表列下限 [`version_list_min_width`] = 行宽上限 **424**):
//!
//! - `888` = `available_width(1024)`:任务给的产品口径「≫1024 两栏都展开」,换成可用宽就是它;
//! - `676` = `64 + 24 + 140 + 24 + 424`:**类型栏收成图标条之后**三栏仍能让列表停在 424;
//! - `600` = `64 + 24 + 64 + 24 + 424`:两条栏都收成 64 也救不回来时,三栏这件事就不成立了。
//!
//! ## 3. 列表列下限为什么是 424(这条是硬的)
//!
//! 版本行要**单行不折**地放「版本号 + 类型 + 日期 + 安装按钮」。424 是 [`super::measure_cap_px`]
//! ——正文行宽上限(逐行实测 67 字符 ≤72),比这一行真正需要的宽得多:
//! `1.21.9-pre3`(11 字符 `BodyMedium` 14px ≈ 67)+ `Snapshot`(`LabelMedium` 12px ≈ 50)
//! + `2026-09-20`(10 字符 ≈ 52)+ 安装按钮(`control_height` 40 高的 text 按钮 ≈ 76)
//! + 两侧内边距 2×16 + 三处间距 3×12 = **293**。取 424 不是「凑」:它是本仓既有的
//! 行宽红线,列表列比它还窄就等于在 424 的正文列旁边又开了一条更窄的正文列。
//!
//! ## 4. 数值表(报告里贴的是同一张)
//!
//! 页面拿到的宽度 → 可用宽 `A`(扣 rail 88 与 2×24 页边距)→ 档位 → 三列。
//! 「右栏收起 / 展开」列按 M104 的 `content_width`(窗口 − 右栗 − 拖拽缝)计算:
//!
//! | 窗口 | 右栏 | 页面拿到 | `A` | 档 | 类型 | 主版本 | 列表 |
//! |---|---|---|---|---|---|---|---|
//! | 1180 | 无(本分支) | 1180 | 1044 | Wide | 140 | 140 | **716** |
//! | 1180 | 收起 48 | 1132 | 996 | Wide | 140 | 140 | **668** |
//! | 1180 | 展开 320 | 852 | 716 | Narrow | 64 | 140 | **464** |
//! | 1024 | 无 | 1024 | 888 | Wide | 140 | 140 | **560** |
//! | 900 | 无 | 900 | 764 | Narrow | 64 | 140 | **512** |
//! | 880 | 无 | 880 | 744 | Narrow | 64 | 140 | **492** |
//! | 700 | 无 | 700 | 564 | Chips | — | — | **564** |
//!
//! **任务书那句「1180−88−320−140−140 = 492」算错了**:它漏掉 2×24 页边距与 2×24 栏间距
//! (共 96)。按真实链路,1180 展开右栏后页面拿到 **852**,可用宽 **716**,
//! 两条 140 栏 + 两处间距要 328 ⇒ 列表只剩 **388 < 424**,**那正是「一条栏必须收成 64」的
//! 触发条件**;收到 64 之后列表是 **464**(不是 492)。492 这个数在 **880 窗宽**上成立
//! (744 − 252 = 492),两者差 28 就是 1180−852−88−48 里被漏掉的那部分。
//!
//! ## 5. 为什么 Narrow 档收的是**类型栏**而不是主版本栏
//!
//! 两栏都放 3~5 个字符的短串,但可辨性不同:类型栏是**四个固定项**(有图标可认:列表 / 星 /
//! 相机 / 历史),主版本栏放的是**用户正在找的那串版本号**——同一屏里两种 64 栏,先收有图标的那个。
//! `Compact` 档才把主版本栏也收成 64(`1.21` 在 40 可用宽里仍是完整串,不会被截断)。
use unlucky_design::spacing::Space;
use super::{available_width, measure_cap_px, FrameMetrics};
/// 版本类型栏(第一栏)展开宽。
pub const TYPE_RAIL_WIDTH: f32 = 140.0;
/// 主版本栏(第二栏)展开宽。
pub const MAJOR_RAIL_WIDTH: f32 = 140.0;
/// 两条侧栏收成图标条时的宽度。
pub const RAIL_ICON_WIDTH: f32 = 64.0;
/// 栏与栏之间的间距(与两列版面的 `PANES_GAP` 同值:同一屏里不出现两种分栏节奏)。
pub const RAIL_GAP: f32 = Space::X6.dp();
/// 列表列下限 = 行宽上限 **424**(见模块文档 §3)。
pub fn version_list_min_width() -> f32 {
measure_cap_px()
}
/// `Wide` 档门槛:`available_width(1024)` = **888**(产品口径「≫1024 两栏都展开」)。
pub fn wide_min_available() -> f32 {
available_width(1024.0)
}
/// `Narrow` 档门槛:类型栏 64 + 间距 + 主版本栏 140 + 间距 + 列表下限 = **676**。
pub fn narrow_min_available() -> f32 {
RAIL_ICON_WIDTH + RAIL_GAP + MAJOR_RAIL_WIDTH + RAIL_GAP + version_list_min_width()
}
/// `Compact` 档门槛:两条 64 栏 + 两处间距 + 列表下限 = **600**。
pub fn compact_min_available() -> f32 {
RAIL_ICON_WIDTH + RAIL_GAP + RAIL_ICON_WIDTH + RAIL_GAP + version_list_min_width()
}
/// 版本行里「类型」一格的宽度:最长标签 `Snapshot`(8 字符 × `LabelMedium` 12px ≈ 50)+
/// 一格间距余量,取 4px 栅格 **84**。
pub const LIST_KIND_WIDTH: f32 = 84.0;
/// 版本行里「日期」一格的宽度:ISO 日期 `2026-09-20`(10 字符 ≈ 52)+ 余量,取 **88**。
pub const LIST_DATE_WIDTH: f32 = 88.0;
/// 版本行尾的安装图标按钮格子宽 = 图标按钮边长 **40**(`IconGeometry::side`;命中区 48 由行高 44 补)。
pub const ROW_ICON_BUTTON: f32 = 40.0;
/// 版本行内三处间距(`Space::X2` = 8):比栏间距小一档,行内节奏不与分栏节奏打架。
pub const ROW_GAP: Space = Space::X2;
/// 版本行的最小内容宽(报告里的推导用它当校验):
/// `Listitem 最小格 176 + 84 + 88 + 40 + 3×8 = 412 < 424`。
///
/// 412 略小于行宽下限 424,说明「424 下四样仍在同一行」不是估计而是有 12px 余量的结论;
/// 版本号那一格最窄拿到 `424 − 84 − 88 − 40 − 24 = 188`(比推导里取的 176 还宽 12)。
pub fn version_row_min_width() -> f32 {
176.0 + LIST_KIND_WIDTH + LIST_DATE_WIDTH + ROW_ICON_BUTTON + 3.0 * ROW_GAP.dp()
}
/// 版本管理页此刻用哪一档(四档,见模块文档 §2)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionRailTier {
/// 两栏都展开(`A ≥ 888`)。
Wide,
/// 类型栏收成图标条(`676 ≤ A < 888`)。
Narrow,
/// 两条栏都收成图标条(`600 ≤ A < 676`)。
Compact,
/// 三栏不成立:退回顶部 chips(`A < 600`)。
Chips,
}
impl VersionRailTier {
/// 稳定名字(日志 / 测试 / 出图文件名用)。
pub const fn name(self) -> &'static str {
match self {
Self::Wide => "wide",
Self::Narrow => "narrow",
Self::Compact => "compact",
Self::Chips => "chips",
}
}
/// 这一档还画不画页内侧栏。
pub const fn is_three_column(self) -> bool {
!matches!(self, Self::Chips)
}
}
/// 三栏的宽度裁决结果(页面直接消费,不再自己算)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VersionRails {
/// 档位。
pub tier: VersionRailTier,
/// 版本类型栏宽(`Chips` 档为 0)。
pub type_rail: f32,
/// 主版本栏宽(`Chips` 档为 0)。
pub major_rail: f32,
/// 列表 / 内容列宽。
pub list: f32,
}
impl VersionRails {
/// 按可用宽裁决(门槛见模块文档 §2)。
///
/// 降级是**逐档下沉**的,不做「挤一挤」:每一档都保证 `list ≥ `[`version_list_min_width`],
/// 除非已经沉到 `Chips`(那时列表就是整条可用宽)。
pub fn of(available: f32) -> Self {
let (tier, type_rail, major_rail) = if available >= wide_min_available() {
(VersionRailTier::Wide, TYPE_RAIL_WIDTH, MAJOR_RAIL_WIDTH)
} else if available >= narrow_min_available() {
(VersionRailTier::Narrow, RAIL_ICON_WIDTH, MAJOR_RAIL_WIDTH)
} else if available >= compact_min_available() {
(VersionRailTier::Compact, RAIL_ICON_WIDTH, RAIL_ICON_WIDTH)
} else {
(VersionRailTier::Chips, 0.0, 0.0)
};
let list = if tier.is_three_column() {
(available - type_rail - RAIL_GAP - major_rail - RAIL_GAP).max(0.0)
} else {
available.max(0.0)
};
Self {
tier,
type_rail,
major_rail,
list,
}
}
/// 一屏的版本管理页几何(`metrics` 来自 [`super::frame_metrics`])。
///
/// **读的是块宽**([`super::block::body_width`] = pane 宽封顶 1440,M102 的口径),
/// 不是 `metrics.available`:三栏都长在 `frame::body` 那个块里,超宽窗(≥1576)上
/// 可用宽 2424 > 块宽 1440,按 available 切会让三列之和超出块、右侧被裁。
pub fn of_metrics(metrics: FrameMetrics) -> Self {
Self::of(super::block::body_width(metrics))
}
/// 叠上「用户手动折叠了类型栏」这条覆盖。
///
/// 只动第一栏:收窄到 [`RAIL_ICON_WIDTH`],省下来的宽**原样进列表列**(折叠只会让列表更宽,
/// 所以不需要再校验一次 424 下限)。`Chips` 档没有第一栏,返回原值。
#[must_use]
pub fn with_type_folded(self) -> Self {
if !self.tier.is_three_column() || self.type_rail <= RAIL_ICON_WIDTH {
return self;
}
Self {
type_rail: RAIL_ICON_WIDTH,
list: self.list + (self.type_rail - RAIL_ICON_WIDTH),
..self
}
}
}
#[cfg(test)]
mod tests;