From 89a51a63b152eef967f95eb6bd15876e45e87014 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 12:55:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-iced):=20M107=20DLVRAIL=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=89=88=E6=9C=AC=E7=AE=A1=E7=90=86=E9=A1=B5?= =?UTF-8?q?=E4=B8=89=E6=A0=8F=E4=BE=A7=E6=A0=8F=20+=20=E8=B7=A8=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户令(信息架构):这一页从「下载」改称「版本管理」,版面从「一排 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 张:三档 × 浅深 + 搜索命中 + 空 + 异地 + 远古版) --- crates/app-iced/src/app/update/download.rs | 39 +++ .../app-iced/src/app/update/page_messages.rs | 8 +- crates/app-iced/src/pages/download.rs | 151 +++++---- crates/app-iced/src/pages/download/demo.rs | 42 ++- crates/app-iced/src/pages/download/group.rs | 148 ++++++++ .../src/pages/download/group/tests.rs | 104 ++++++ crates/app-iced/src/pages/download/model.rs | 145 ++++---- .../src/pages/download/model/category.rs | 85 +++++ crates/app-iced/src/pages/download/rails.rs | 320 ++++++++++++++++++ crates/app-iced/src/pages/download/rows.rs | 250 +++++++++++--- .../app-iced/src/pages/download/rows/tests.rs | 41 +++ crates/app-iced/src/pages/download/states.rs | 70 ++++ crates/app-iced/src/pages/download/tests.rs | 252 ++++++-------- .../app-iced/src/pages/download/tests/i18n.rs | 13 +- .../src/pages/download/tests/rails.rs | 81 +++++ .../app-iced/src/pages/download/tests/view.rs | 147 ++++++++ crates/app-iced/src/pages/layout.rs | 10 + crates/app-iced/src/pages/layout/rails.rs | 227 +++++++++++++ .../app-iced/src/pages/layout/rails/tests.rs | 139 ++++++++ crates/app-iced/src/pages/message.rs | 15 + crates/app-iced/src/pages/screen.rs | 2 +- crates/i18n/src/catalog.rs | 7 +- crates/i18n/src/catalog/download.rs | 33 ++ crates/i18n/src/catalog/golden.rs | 11 + 24 files changed, 2000 insertions(+), 340 deletions(-) create mode 100644 crates/app-iced/src/pages/download/group.rs create mode 100644 crates/app-iced/src/pages/download/group/tests.rs create mode 100644 crates/app-iced/src/pages/download/model/category.rs create mode 100644 crates/app-iced/src/pages/download/rails.rs create mode 100644 crates/app-iced/src/pages/download/rows/tests.rs create mode 100644 crates/app-iced/src/pages/download/tests/rails.rs create mode 100644 crates/app-iced/src/pages/download/tests/view.rs create mode 100644 crates/app-iced/src/pages/layout/rails.rs create mode 100644 crates/app-iced/src/pages/layout/rails/tests.rs diff --git a/crates/app-iced/src/app/update/download.rs b/crates/app-iced/src/app/update/download.rs index 24ba6ec..fec0571 100644 --- a/crates/app-iced/src/app/update/download.rs +++ b/crates/app-iced/src/app/update/download.rs @@ -83,6 +83,21 @@ impl App { self.dispatch(AppCommand::ListVersions(kind)); } } + // M107:查询词落在快照里(无状态视图的唯一存活方式),再走同一条重建路径过滤。 + pages::Message::SetVersionQuery(query) => { + self.rebuild_after_query(query.clone()); + } + pages::Message::ClearVersionQuery => { + self.rebuild_after_query(String::new()); + } + // M107:主版本桶的选中是纯视图态,不重建、不派命令(同 `SelectVersion`)。 + pages::Message::SelectMajor(key) => { + self.view.download.selected_major = key.clone(); + } + // M107:类型栏的手动折叠也是纯视图态。 + pages::Message::ToggleTypeRail => { + self.view.download.type_rail_folded = !self.view.download.type_rail_folded; + } pages::Message::Action(pages::Action::RefreshSources) => { match self.view.download.category.command() { Some(kind) => self.dispatch(AppCommand::ListVersions(kind)), @@ -151,4 +166,28 @@ impl App { mc_version: version, }); } + + /// 搜索词变了:写进快照 → 重建视图 → **把纯视图态捡回来**。 + /// + /// `refresh_view()` 整块换掉 `self.view`,而它只把 `category` 与**安装草稿**带回去(`app/state.rs` + /// 归 M105/M106)。所以「折叠状态」与「选中的主版本桶」必须在重建前后自己搬一次——否则用户敲第一个 + /// 字符,展开着的类型栏就自己收起来、选中的主版本也跳回「全部」。 + /// 「搜索时侧栏保留当前位置」这条要求,落点就是这里的四行。 + fn rebuild_after_query(&mut self, query: String) { + self.state.download_query = query; + let major = self.view.download.selected_major.take(); + let folded = self.view.download.type_rail_folded; + self.view_dirty = true; + self.refresh_view(); + // 回填要**校验**:桶是按当前行聚合法出来的,新桶列表里没有的那个选择不该被复活 + // (复审 P2-1(a):否则会出现「侧栏没有任何一项点亮」的幽灵选中)。 + self.view.download.selected_major = major.filter(|key| { + self.view + .download + .buckets + .iter() + .any(|bucket| bucket.key.as_deref() == Some(key.as_str())) + }); + self.view.download.type_rail_folded = folded; + } } diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index ad442d8..ee4f6c3 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -63,10 +63,16 @@ impl App { pages::Message::SelectVersion(_) | pages::Message::InstallVersion(_) | pages::Message::CancelInstall + // M105 的三支 + M107 的四支 + 分类:全部由 `on_download` 提前消化(显式列出保住穷尽性) | pages::Message::SetLoader(_) | pages::Message::SetLoaderVersion(_) | pages::Message::SetInstanceName(_) - | pages::Message::SetCategory(_) => debug_assert!(false, "应由 on_download 消费"), + | pages::Message::SetCategory(_) + // M107 的四支同属下载页(查询 / 清空 / 主版本桶 / 折叠类型栏) + | pages::Message::SetVersionQuery(_) + | pages::Message::ClearVersionQuery + | pages::Message::SelectMajor(_) + | pages::Message::ToggleTypeRail => debug_assert!(false, "应由 on_download 消费"), // M95:清空日志是**呈现层**操作(`log_lines` 是 presenter 的公开快照字段, // 不派业务命令、不写持久状态) pages::Message::Action(pages::Action::ClearLogs) => { diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index 6e150b3..b207cad 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -1,39 +1,32 @@ -//! 下载页:**单列**版面的用例(页面块吃满页宽,散文才受 424 行宽约束)。 +//! **版本管理页**(原下载页):三栏版面 —— 版本类型 / 主版本 / 列表 + 安装面板。 //! -//! 这一页永远单列,理由不是「简单」,而是官方对「一次只做一件事」的入口(下载队列)的口径: -//! 内容是一串线性进度项,分栏没有第二个阅读对象。 -//! -//! **M102 订正**:旧版把整页块压到 424 并水平居中(1180 宽下左缘 422,页头标题在 112, +//! 用户令(信息架构调整)把这一页从「下载」改称「版本管理」:下载页只负责整合包与 Mod/光影, +//! 而这一页管的是**游戏版本与加载器安装**。本文件是那次改版后的页面装配点。 +//! **M102 版面订正**:旧版把整页块压到 424 并水平居中(1180 宽下左缘 422、页头标题在 112, //! 左右各空 ~300px,视觉复审判为「内容缩在中间一条窄栏」)。现在页面块走 [`frame::body`] -//! ——左缘与页头标题同一条线(= 页边距 24),宽度 = 可用宽度(1180 下 1044)。 -//! 424 只约束**成段散文**(本页没有,散文在实例详情页),列表行 / Tabs / 分隔线吃满页宽。 +//! ——左缘与页头标题同一条线(= 页边距 24),宽 = 块宽 `frame::body_width`(1180 下 1044, +//! 极宽窗封顶 **1440**)。424 只约束**成段散文**(本页没有),三栏的每一栏都吃块宽。 //! -//! ## M94:占位控件 → 真组件 + 真数据 + 真指令(逐项对照) +//! ## 版面:三栏,宽度由布局系统裁决 //! -//! | 位置 | M84(占位) | M94(真组件) | -//! |---|---|---| -//! | 页头动作 | `frame::text_action`(`text` + 手搓 hover) | `buttons::text`(五式 + 状态层) | -//! | 分类 | 无 | `navigation::tabs`(Primary,48 高 / 3px `Primary` 指示条) | -//! | 列表行 | 无 | `navigation::list_item`(两行档 **60**:`row_height` 44 + 每多一行 16) | -//! | 分隔线 | `frame::divider` | `components::divider::full` / `list` | -//! | 安装按钮 | 无 | `buttons::filled`(**本页唯一实心 accent**,rubric K2) | -//! | 进度 | 无 | `feedback::progress::linear`(已知 = determinate,未知 = indeterminate) | -//! | 空态 | `frame::empty_state` | `feedback::empty_state`(`EmptyState` 真组件) | -//! | 数据 | `"3 sources healthy · …"` 硬编码假状态行 | [`Download`](`app/view.rs` 从 presenter 快照转) | -//! | 文案 | 英文字面量 | `unlucky_i18n` 的 `Key`(拼错编译失败;中英两套目录) | +//! ```text +//! ┌─────────┬─────────┬──────────────────────────────┐ +//! │ 版本类型 │ 主版本 │ 搜索条 · 命中数 · 计数头 │ +//! │ 140/64 │ 140/64 │ 版本行(单行四项) │ +//! │ │ │ … │ +//! │ │ │ 安装面板(M105 的选项区在这里)│ +//! └─────────┴─────────┴──────────────────────────────┘ +//! ``` //! -//! ## 状态矩阵(四个状态都能从快照推出来,都出图) +//! 三个数字**没有一个是在本文件里拍的**:它们来自 +//! [`VersionRails`](super::layout::VersionRails)(宽档裁决 + 数值表在 `pages/layout/rails.rs`)。 +//! 页面只做一件事:把裁决结果交给 [`rails`] 画出来。 //! -//! | 状态 | 条件 | 组件 | -//! |---|---|---| -//! | 加载 | 快照里没有版本列表([`Download::is_loading`]) | 标题 + 说明,**居中、无进度形状**([`states::loading`]) | -//! | 空 | 列表拉到了、过滤后为空([`Download::is_empty`]) | `EmptyState` + 刷新 CTA,**同样居中**([`states::empty`]) | -//! | 有数据 | 有版本行 | Tabs + 计数头 + `ListItem` 列表([`rows`]) | -//! | 安装中 | `AppState::install` 在跑 | 阶段 + **进度条(仅当进度已知)** + 计数 + 「取消」([`install`]) | -//! | 失败 | `install.stage == Failed` | **`ErrorContainer` 面板**:`Error` 图标/标题 + 实心「打开日志」,**没有进度条**([`install`]) | +//! ## 原顶部 4 个 tab 的去处(不是新造分类) //! -//! 两个占位状态(加载 / 空)走**同一套规则**:内容在可用区居中(依据:`feedback::empty_state` -//! 的既有口径 `12b §15.4`,且两态互切时内容不会纵向跳)。 +//! 四个 chip(全部 / 正式版 / 预览版 / 远古版)**语义一字不改**,只是从顶部横排搬到第一栏竖排; +//! 窄到三栏不成立时(可用宽 < 600)它们**原样回到顶部**([`rails::stacked_chips`])——同一条 +//! [`Category`] 谓词、同一条 `AppCommand::ListVersions`。 //! //! ## 意图 → 消息 → 指令(页面只发意图,不做乐观回显) //! @@ -57,17 +50,51 @@ //! 进度条只读 `model.install`:它由 presenter 的 `InstallProgress` 事件经 `apply` 灌进来。 //! 于是「点了没变」只有一种解释——后端还没回事件,这正是要的(P3-2 的纪律)。 //! -//! ## 版面判据(M102 修订了「页头 vs 正文」那一条) +//! ## M107:两级侧栏从哪来(第一级 = 版本类型,第二级 = 主版本桶) //! -//! 正文块左缘 = 页头标题左缘 = `rail 88 + gutter 24 = 112`([`frame::body`]); -//! 断点 600 / 840、rail 88、散文行宽 ≤424([`super::layout::measure_cap_px`])都由 -//! [`super::layout`] 与 `shell` 无条件施加。视觉复审偏离 #1(页头铺满、不与正文列对齐) -//! 因此**收口**:页头与正文块现在共用同一条左缘,右缘也都停在页边距上。 +//! 第一级 = 版本类型(4 项,固定);第二级 = 主版本桶(由快照里的版本 id 按 [`group`] 的规则归纳, +//! 远古版这类「没有主版本概念」的只有「全部版本」一项)。 +//! +//! ## 搜索(三层口径) +//! +//! | 情形 | 画面 | +//! |---|---| +//! | 有命中 | 列表照常;侧栏**位置不动**;搜索条右侧给「N 个匹配」(跨类型读数) | +//! | 一个都不命中 | [`states::no_match`]:没有匹配 + 换短片段 + 清空 | +//! | 有命中但不在当前类型 | [`states::elsewhere`]:N 个在别的类型 + 「看全部类型」 | +//! +//! 查询词写在 presenter 快照的 `download_query` 里(页面无状态,见 [`model::Download::from_snapshot`]), +//! 所以「搜索 → 任何事件回来之后仍然在搜」这条不需要页面持有任何字段。 +//! +//! ## 状态矩阵(加载 / 空 / 有数据 / 安装中 / 失败) +//! +//! 与 M94 时一致,唯一的改动是**空态与搜索态都留在三栏骨架里**:侧栏与搜索框照旧在, +//! 只有列表区换成占位内容——白屏会让人以为列表坏了。 +//! +//! ## 两个纯视图态的字段(快照里没有,重建会丢) +//! +//! `selected_major`(第二级侧栏选中)与 `type_rail_folded`(类型栏手动折叠)**不在 presenter 快照里** +//! (`AppState` 没有这两个字段,加字段要动 `pages/mod.rs` 的偏离表与 presenter,见 M107 报告)。 +//! 所以它们与既有的 `selected` 同一档处理:`on_download` 直接改视图模型,**不重建**; +//! 唯一会重建的路径是搜索(`rebuild_after_query`),那里把两个字段在重建前后各搬一次。 +//! 其余事件(安装进度、版本列表刷新)回来时这两个字段会回到默认值——与 `selected` 的既有缺口同源, +//! 长期要靠 presenter 加字段,已登记在报告里。 +//! +//! ## 版面判据(M102 口径:页面块 vs 散文列) +//! +//! 页面仍是 `PaneKit::Single`(页内自己切三栏)。整页内容走 [`frame::body`]:块左缘 = +//! 页头标题左缘 = `rail 88 + gutter 24 = 112`,块宽 = [`frame::body_width`](pane 宽封顶 +//! **1440**)——视觉复审 ①(内容缩在中间一条窄栏、与页头错位)因此收口。 +//! **三栏的列宽从「块宽」算**([`VersionRails::of_metrics`] 读 `frame::body_width`,不是 +//! `metrics.available`):否则 1576+ 的超宽窗上三列之和会超出 1440 的块、右侧被裁。 +//! 424([`super::layout::measure_cap_px`])在本页只对**成段散文**有效——这一页没有散文。 mod demo; +mod group; mod install; mod model; mod options; +mod rails; mod rows; mod states; @@ -90,7 +117,7 @@ use crate::app::components::divider; use crate::theme::Ui; use super::frame::{self, Header}; -use super::layout::FrameMetrics; +use super::layout::{FrameMetrics, VersionRails}; use super::{Action, Message}; /// 整页(**唯一**的真数据入口:`model` 由 `app/view.rs` 从 presenter 快照建)。 @@ -118,24 +145,18 @@ pub fn view<'a>( frame::page(ui, metrics, header, body(ui, metrics, catalog, model)) } -/// 正文:加载 / 空 / 列表 + 底部安装面板(列宽由 [`super::layout`] 决议,页面只吃结果)。 +/// 正文:加载态仍是居中占位;其余状态都长在三栏骨架上。 fn body<'a>( ui: &'a Ui, metrics: FrameMetrics, catalog: Catalog, model: &'a Download, ) -> Element<'a, Message> { - if model.is_loading() || model.is_empty() { - let placeholder = if model.is_loading() { - states::loading(ui, catalog) - } else { - states::empty(ui, catalog) - }; - // 占位状态下**也**把安装面板带上(复审 p3):列表空/还没到,不代表安装停了—— - // 没有这一段的话,安装进行中一旦列表被过滤空,进度条会凭空消失。 + if model.is_loading() { + // 还没拉到版本列表:三栏没有内容可填,给占位(安装面板照旧带着,见 M94 复审 p3) let content: Element<'a, Message> = if model.install.is_some() { column![ - placeholder, + states::loading(ui, catalog), divider::full(ui), install::panel(ui, catalog, model) ] @@ -144,23 +165,37 @@ fn body<'a>( .height(Length::Fill) .into() } else { - placeholder + states::loading(ui, catalog) }; return frame::body(metrics, content); } - let content: Element<'a, Message> = column![ - rows::categories(ui, catalog, model.category), - rows::head(ui, catalog, model), - divider::full(ui), - rows::list(ui, catalog, model), - divider::full(ui), - install::panel(ui, catalog, model), - ] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .height(Length::Fill) - .into(); + // 宽度裁决:档位来自布局系统,「用户手动折叠了类型栏」这一条覆盖叠在上面。 + let rails = { + let rails = VersionRails::of_metrics(metrics); + if model.type_rail_folded { + rails.with_type_folded() + } else { + rails + } + }; + let content: Element<'a, Message> = if rails.tier.is_three_column() { + rails::three_column(ui, catalog, model, rails) + } else { + // 三栏不成立(可用宽 < 600):类型与主版本原样退回顶部 chips,列表吃掉整条可用宽 + column![ + rails::stacked_chips(ui, catalog, model), + divider::full(ui), + rows::content(ui, catalog, model), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .height(Length::Fill) + .into() + }; + + // M102:整页块走 `frame::body`(贴左、块宽封顶 1440),与页头标题同一条左缘。 + // 三栏都在这个块里切,所以 `VersionRails` 读的是块宽而不是 `metrics.available`。 frame::body(metrics, content) } diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index 0d17600..0592b69 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -20,7 +20,12 @@ //! //! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `category-release` / `installing` / //! `installing-late` / `installing-empty` / `failed` / `done` / `done-next` / -//! **`options` / `options-fabric` / `options-incompat`**(M105 的安装选项区三态)。 +//! **`options` / `options-fabric` / `options-incompat`**(M105 的安装选项区三态)/ +//! **`category-ancient` / `search` / `search-major` / `search-empty` / `search-elsewhere`** +//! (M107 的两级侧栏与搜索取样点)。 +//! +//! M107 那几个取样点**都走产品那条真转换**(`Download::from_snapshot`,查询词写在快照的 +//! `download_query` 里):图证明的是产品路径的行为,不是演示的特权。 //! //! `category-release` / `done` / `done-next` 都**不手搓结果**,而是拿一份 `AppState` 跑 //! [`Download::from_snapshot`]:图证明的是产品路径的行为(分类本地过滤、装完后面板让回列表), @@ -92,6 +97,13 @@ pub fn demo(catalog: Catalog) -> Download { // 安装进行中、但列表被过滤空:面板要跟着占位态一起在(复审 p3 的边角) // 分类筛选(本页本地过滤:真 host 丢弃 ListVersions 的分类参数)——混合清单里只留正式版 Some("category-release") => category(catalog, Category::Release), + // M107 的两级侧栏 + 搜索:这三种状态**都走产品那条真转换**(from_snapshot), + // 图里看到的是页面真会画的东西,不是演示的特权。 + Some("category-ancient") => searched(catalog, Category::Ancient, ""), + Some("search") => searched(catalog, Category::All, "1.20"), + Some("search-major") => majored(searched(catalog, Category::All, ""), "1.21"), + Some("search-empty") => searched(catalog, Category::All, "zzz"), + Some("search-elsewhere") => searched(catalog, Category::Release, "24w"), Some("installing-empty") => Download { install: Some(install(InstallStage::Downloading, 0.42, catalog)), ..Download::loading() @@ -100,7 +112,7 @@ pub fn demo(catalog: Catalog) -> Download { } } -/// 有数据:六行(三条正式、一条预览、两条远古),与 presenter 的 `mock_versions` 同形。 +/// 有数据:八行(四条正式、两条预览、两条远古),覆盖四种版本形态与三个主版本桶。 /// /// 不复用 `mock_data::mock_versions()`:演示数据是**出图的基线**,得比 mock 稳定 /// (mock 随 P3 的假数据一起漂)。 @@ -114,11 +126,16 @@ fn ready(catalog: Catalog) -> Download { Download { // 混合分类 → 没有单个 chip 该亮(这正是 `Category::of` 的反推结果) category: Category::of(&versions), + buckets: super::group::buckets(&rows), + hits: rows.len(), rows, selected: None, options: None, install: None, loaded: true, + query: String::new(), + selected_major: None, + type_rail_folded: false, } } @@ -147,6 +164,9 @@ fn options(catalog: Catalog, version: &str, loader: Loader, cached: Option Vec { fn summary(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary { VersionSummary { @@ -161,7 +181,9 @@ fn demo_versions() -> Vec { summary("1.21.9", VersionKind::Release, "2026-09-20", false), summary("1.21.8", VersionKind::Release, "2026-07-11", true), summary("1.20.4", VersionKind::Release, "2023-12-07", true), + summary("1.20.1", VersionKind::Release, "2023-06-12", false), summary("1.21.9-pre3", VersionKind::Snapshot, "2026-09-01", false), + summary("24w14a", VersionKind::Snapshot, "2024-04-03", false), summary( "rd-132211", VersionKind::Ancient(AncientKind::Classic), @@ -214,6 +236,22 @@ fn category(catalog: Catalog, wanted: Category) -> Download { Download::from_snapshot(&state, wanted, &InstallDraft::default(), catalog) } +/// 「用户在搜索框里敲了几个字符」的演示:查询词写进**快照字段**(产品路径就是这么活的)。 +fn searched(catalog: Catalog, wanted: Category, query: &str) -> Download { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged( + demo_versions(), + ))); + state.download_query = query.to_string(); + Download::from_snapshot(&state, wanted, &InstallDraft::default(), catalog) +} + +/// 「用户点了第二级侧栏里的一个主版本桶」的演示(纯视图态,与 `on_download` 的落点一致)。 +fn majored(mut model: Download, key: &str) -> Download { + model.selected_major = Some(key.to_string()); + model +} + /// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。 fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView { let total = 512; diff --git a/crates/app-iced/src/pages/download/group.rs b/crates/app-iced/src/pages/download/group.rs new file mode 100644 index 0000000..d166ee0 --- /dev/null +++ b/crates/app-iced/src/pages/download/group.rs @@ -0,0 +1,148 @@ +//! 主版本分组:把一串版本 id 归成「大版本」桶(第二级侧栏的数据源)。 +//! +//! ## 为什么不能按第一个 `.` 切 +//! +//! 真实的版本清单里同时存在**四种形态**(拿真样本说,不用「第几个点」这种相对说法): +//! +//! | 样本 | 是什么 | `split('.')` 会得到什么 | 正确的主版本 | +//! |---|---|---|---| +//! | `1.21.4` | 正式版 | `["1", "21", "4"]` | `1.21` | +//! | `1.21.9-pre3` | 预发布(正式版的兄弟) | `["1", "21", "9-pre3"]` | `1.21` | +//! | `24w14a` | 周更快照 | `["24w14a"]`(一个都没有) | `24w`(周更的年桶) | +//! | `rd-132211` / `a1.2.6` | 远古版(Classic / Alpha) | `["rd-132211"]` / `["a1", "2", "6"]` | **没有主版本** | +//! +//! 「按第一个 `.` 切」在 `1.21.4` 上给出 `1`,把 1.7 到 1.21 的全部正式版塞进一个桶; +//! 在 `a1.2.6` 上给出 `a1`,凭空造出一个「主版本」——远古版根本没有 major.minor 这层概念。 +//! +//! ## 规则(三条,按顺序命中即返回) +//! +//! 1. **带点的数值前缀**(正式版 / 预发布):id 在第一个 `-` 之前的部分,前两段都是纯数字 +//! ⇒ 主版本 = `第一段.第二段`。`1.21.9-pre3` 先切掉 `-pre3` 再取 `1.21`。 +//! 2. **周更快照**:id 形如 `24w14a`(两位年 + `w` + 两位周 + 一个修订字母)⇒ 主版本 = `24w`。 +//! 周更版本每年几十个,年桶是它的「大版本」等价物;不按周切是因为一周通常只有 1 个版本, +//! 按周分组等于每个桶一个版本(侧栏退化成重复的列表)。 +//! 3. **其余**:返回 [`None`](没有主版本概念)。远古版(`rd-132211` / `a1.2.6` / `c0.30_01c`) +//! 走这一条 ⇒ 第二级侧栏只有一个「全部」项。 +//! +//! 判据是**形态**(有没有 `-` 后缀、前两段是不是纯数字、有没有 `NNwNNx` 的形状), +//! 不是「版本号大小」,所以新增一个 `1.22.0` 或 `25w01a` 不需要改规则。 + +use unlucky_presenter::dto::VersionKind; + +/// 主版本桶的一个标签(`1.21` / `24w`);没有主版本概念时是 [`None`]。 +pub type MajorKey = String; + +/// 这个版本属于哪个主版本桶(规则见模块文档)。 +/// +/// 远古版**直接**返回 [`None`]:它们的 id 形态(`a1.2.6`)与正式版同族,靠形态规则会误判, +/// 所以先按 `kind` 分流——「有没有主版本」是**版本类型**的属性,不是 id 字符串的属性。 +#[must_use] +pub fn key(id: &str, kind: VersionKind) -> Option { + if kind.is_ancient() { + return None; + } + + // 规则 1:切掉 `-pre3` / `-rc1` 这类后缀,再取前两段数值 + let head = id.split('-').next().unwrap_or(id); + let mut parts = head.split('.'); + if let (Some(major), Some(minor)) = (parts.next(), parts.next()) { + if is_number(major) && is_number(minor) { + return Some(format!("{major}.{minor}")); + } + } + + // 规则 2:周更快照 `24w14a` → `24w` + if let Some((year, tail)) = head.split_once('w') { + let week_like = tail.chars().next().is_some_and(|ch| ch.is_ascii_digit()); + if year.len() == 2 && is_number(year) && week_like { + return Some(format!("{year}w")); + } + } + + // 规则 3:没有主版本概念 + None +} + +/// 纯数字(空串不算)。 +fn is_number(text: &str) -> bool { + !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// 两条主版本标签的**版本序**(降序用):`1.21 > 1.9`、`24w > 14w`,数字按数值比、字母按字节比。 +/// +/// 为什么不是字符串序:`"1.9" > "1.21"` 在字典序里成立(`9 > 2`),而版本序里 1.21 更新。 +/// 逐段比:连续数字读成整数、其余字符逐字节比。 +#[must_use] +pub fn compare(left: &str, right: &str) -> std::cmp::Ordering { + let mut left_rest = left; + let mut right_rest = right; + + loop { + match (left_rest.chars().next(), right_rest.chars().next()) { + (None, None) => return std::cmp::Ordering::Equal, + (None, Some(_)) => return std::cmp::Ordering::Less, + (Some(_), None) => return std::cmp::Ordering::Greater, + (Some(left_char), Some(right_char)) => { + if left_char.is_ascii_digit() && right_char.is_ascii_digit() { + let (left_number, left_tail) = take_number(left_rest); + let (right_number, right_tail) = take_number(right_rest); + match left_number.cmp(&right_number) { + std::cmp::Ordering::Equal => { + left_rest = left_tail; + right_rest = right_tail; + } + other => return other, + } + } else if left_char == right_char { + left_rest = &left_rest[left_char.len_utf8()..]; + right_rest = &right_rest[right_char.len_utf8()..]; + } else { + return left_char.cmp(&right_char); + } + } + } + } +} + +/// 取出开头的连续数字(返回数值与剩余部分)。 +fn take_number(text: &str) -> (u64, &str) { + let end = text + .find(|ch: char| !ch.is_ascii_digit()) + .unwrap_or(text.len()); + (text[..end].parse().unwrap_or(0), &text[end..]) +} + +/// 一个主版本桶(第二级侧栏的一行):桶标签 + 桶里有多少个版本。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bucket { + /// 桶标签(`1.21` / `24w`);[`None`] = 这一组版本没有主版本概念(远古版)。 + pub key: Option, + /// 桶里的版本数(列表列与命中数都读它)。 + pub count: usize, +} + +/// 把版本行归成桶:**按版本序降序**(`1.21` 在 `1.20` 前面),无主版本概念的桶排最后。 +/// +/// 只做聚合与排序,不决定「当前选中哪个桶」——那是页面状态,住在 `pages/Download` 里。 +#[must_use] +pub fn buckets(rows: &[super::model::VersionRow]) -> Vec { + let mut grouped: Vec = Vec::new(); + for row in rows { + let key = row.major(); + match grouped.iter_mut().find(|bucket| bucket.key == key) { + Some(bucket) => bucket.count += 1, + None => grouped.push(Bucket { key, count: 1 }), + } + } + + grouped.sort_by(|left, right| match (&left.key, &right.key) { + (Some(a), Some(b)) => compare(b, a), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); + grouped +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/download/group/tests.rs b/crates/app-iced/src/pages/download/group/tests.rs new file mode 100644 index 0000000..6a714f9 --- /dev/null +++ b/crates/app-iced/src/pages/download/group/tests.rs @@ -0,0 +1,104 @@ +//! 分组规则的判据:**用真实版本样本**钉住(不用「第几个点」这种相对说法,踩坑 T1)。 +//! +//! 每条都写清抓得住什么 / 抓不住什么。 + +use super::*; +use unlucky_presenter::dto::{AncientKind, VersionKind}; + +const RELEASE: VersionKind = VersionKind::Release; +const SNAPSHOT: VersionKind = VersionKind::Snapshot; + +fn ancient(kind: AncientKind) -> VersionKind { + VersionKind::Ancient(kind) +} + +/// 四种形态**同时存在**时各自归到哪个桶(这是模块文档那张表的产品化版本)。 +/// +/// 抓得住:有人改回「按第一个 `.` 切」——`1.21.4` 会变成 `1`、`a1.2.6` 会变成一个假主版本。 +/// 抓不住:真实清单里还没出现的形态(比如将来的 `1.22.0`,它走同一条数值规则所以不会红)。 +#[test] +fn the_four_shapes_land_in_the_documented_buckets() { + // 1. 正式版 + assert_eq!(key("1.21.4", RELEASE).as_deref(), Some("1.21")); + assert_eq!(key("1.9.4", RELEASE).as_deref(), Some("1.9")); + assert_eq!(key("1.7.10", RELEASE).as_deref(), Some("1.7")); + // 2. 预发布:切掉 `-pre3` 之后与它的正式版同一个桶 + assert_eq!(key("1.21.9-pre3", SNAPSHOT).as_deref(), Some("1.21")); + assert_eq!(key("1.21-rc1", SNAPSHOT).as_deref(), Some("1.21")); + assert_eq!( + key("1.21.9-pre3", SNAPSHOT), + key("1.21.4", RELEASE), + "预发布必须和它的正式版同桶" + ); + // 3. 周更快照:年桶 + assert_eq!(key("24w14a", SNAPSHOT).as_deref(), Some("24w")); + assert_eq!(key("14w02a", SNAPSHOT).as_deref(), Some("14w")); + // 4. 远古版:没有主版本 + assert_eq!(key("rd-132211", ancient(AncientKind::Classic)), None); + assert_eq!(key("a1.2.6", ancient(AncientKind::OldAlpha)), None); + assert_eq!(key("c0.30_01c", ancient(AncientKind::Classic)), None); + assert_eq!(key("b1.7.3", ancient(AncientKind::OldBeta)), None); +} + +/// 「按第一个 `.` 切」的坏法要留下痕迹:同一族的版本不许被拆成两个桶。 +#[test] +fn splitting_on_the_first_dot_would_break_these_samples() { + // 1.7 / 1.9 / 1.21 是三个不同的桶(按第一个点切会全归到 "1") + let buckets = ["1.7.10", "1.9.4", "1.21.4", "1.21.1"] + .map(|id| key(id, RELEASE).expect("正式版都有主版本")) + .to_vec(); + assert_eq!(buckets, vec!["1.7", "1.9", "1.21", "1.21"]); + assert_ne!(buckets[0], buckets[2]); +} + +/// 版本序:`1.21 > 1.9`(字典序会说反),周更年桶同理。 +#[test] +fn the_version_order_is_numeric_not_lexicographic() { + use std::cmp::Ordering; + assert_eq!(compare("1.21", "1.9"), Ordering::Greater); + assert_eq!(compare("1.9", "1.21"), Ordering::Less); + assert_eq!(compare("24w", "14w"), Ordering::Greater); + assert_eq!(compare("1.21", "1.21"), Ordering::Equal); + + let mut labels = vec!["1.9", "1.21", "24w", "1.7"]; + labels.sort_by(|left, right| compare(right, left)); + assert_eq!(labels, vec!["24w", "1.21", "1.9", "1.7"]); +} + +/// 桶聚合:数量正确、按版本序降序、无主版本的桶排在最后。 +#[test] +fn buckets_are_counted_and_sorted() { + use unlucky_i18n::Lang; + + let catalog = Lang::En.catalog(); + let rows: Vec = [ + ("1.21.9", RELEASE), + ("1.21.8", RELEASE), + ("1.20.4", RELEASE), + ("24w14a", SNAPSHOT), + ("rd-132211", ancient(AncientKind::Classic)), + ] + .into_iter() + .map(|(id, kind)| { + super::super::model::VersionRow::of( + &unlucky_presenter::dto::VersionSummary { + id: id.to_string(), + kind, + released: "2026-09-20".to_string(), + installed: false, + }, + catalog, + ) + }) + .collect(); + + let buckets = buckets(&rows); + let keys: Vec> = buckets.iter().map(|bucket| bucket.key.as_deref()).collect(); + assert_eq!( + keys, + vec![Some("24w"), Some("1.21"), Some("1.20"), None], + "24w > 1.21 > 1.20 > 无主版本" + ); + assert_eq!(buckets[1].count, 2, "1.21 桶里两个版本"); + assert_eq!(buckets[3].count, 1, "远古版落在 None 桶"); +} diff --git a/crates/app-iced/src/pages/download/model.rs b/crates/app-iced/src/pages/download/model.rs index 186f505..b2756f2 100644 --- a/crates/app-iced/src/pages/download/model.rs +++ b/crates/app-iced/src/pages/download/model.rs @@ -24,8 +24,7 @@ //! 文案按 `Key` 现求——中文环境自然出中文,英文环境不需要任何中文字体。 use unlucky_presenter::dto::{ - human_bytes, AncientKind, InstallProgressView, InstallStage, VersionCategory, VersionKind, - VersionSummary, + human_bytes, InstallProgressView, InstallStage, VersionKind, VersionSummary, }; use unlucky_presenter::state::AppState; @@ -43,82 +42,9 @@ pub fn cancelled_toast(lang: Lang) -> String { lang.catalog().text(Key::DownloadCancelled).to_owned() } -/// 分类筛选(页面上的四个 filter chip)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Category { - /// 不过滤:画快照里有什么就是什么(首帧与 `RefreshAll` 的落点) - #[default] - All, - /// 正式版 - Release, - /// 预览版 - Snapshot, - /// 远古版(快照里按 `AncientKind` 分四类,页面先只认一个 chip) - Ancient, -} +mod category; -impl Category { - /// 四个 chip 的声明顺序(= 画出来的顺序)。 - pub const ALL: [Category; 4] = [ - Category::All, - Category::Release, - Category::Snapshot, - Category::Ancient, - ]; - - /// chip 文案的 key。 - pub const fn label_key(self) -> Key { - match self { - Category::All => Key::DownloadCategoryAll, - Category::Release => Key::DownloadCategoryRelease, - Category::Snapshot => Key::DownloadCategorySnapshot, - Category::Ancient => Key::DownloadCategoryAncient, - } - } - - /// 点这个 chip 要派的指令(`None` = 不派:`All` 就是「把当前快照全画出来」)。 - /// - /// 远古版在 `VersionCategory` 里是**带子类的**(`Ancient(AncientKind)`),一个 chip 拉不全四类; - /// 这里先拉 `Classic`,四子类同屏要靠 presenter 侧支持(已登记 finding)。 - pub const fn command(self) -> Option { - match self { - Category::All => None, - Category::Release => Some(VersionCategory::Release), - Category::Snapshot => Some(VersionCategory::Snapshot), - Category::Ancient => Some(VersionCategory::Ancient(AncientKind::Classic)), - } - } - - /// 这个版本属于这个分类吗。 - pub fn matches(self, kind: VersionKind) -> bool { - match self { - Category::All => true, - Category::Release => kind == VersionKind::Release, - Category::Snapshot => kind == VersionKind::Snapshot, - Category::Ancient => kind.is_ancient(), - } - } - - /// 从快照里**反推**当前分类:整份列表只有一类 → 那一类;混合或为空 → [`Category::All`]。 - /// - /// 快照没有「我上次按哪个分类拉的」这种字段,所以分类靠列表内容反推 - /// (`ListVersions` 的结果是单一分类,`RefreshAll` 的结果是混合)。 - pub fn of(versions: &[VersionSummary]) -> Self { - let mut kinds = versions.iter().map(|version| version.kind); - let Some(first) = kinds.next() else { - return Category::All; - }; - if kinds.all(|kind| kind == first) { - match first { - VersionKind::Release => Category::Release, - VersionKind::Snapshot => Category::Snapshot, - VersionKind::Ancient(_) => Category::Ancient, - } - } else { - Category::All - } - } -} +pub use category::Category; /// 版本列表的一行(`ListItem` 的两行档:headline = 版本 id,supporting = `正式版 · 发布于 …`)。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -145,16 +71,21 @@ impl VersionRow { supporting: catalog.render( Key::DownloadRowSupporting, &[ - ("kind", catalog.text(kind_key(version.kind))), + ("kind", catalog.text(kind_label_key(version.kind))), ("date", version.released.as_str()), ], ), } } + + /// 这个版本属于哪个主版本桶(规则见 [`super::group`])。 + pub fn major(&self) -> Option { + super::group::key(&self.id, self.kind) + } } /// 版本类型 → 文案 key(远古版不分子类:chip 只有一档,行上再分子类等于两套语言)。 -const fn kind_key(kind: VersionKind) -> Key { +pub(super) const fn kind_label_key(kind: VersionKind) -> Key { match kind { VersionKind::Release => Key::DownloadCategoryRelease, VersionKind::Snapshot => Key::DownloadCategorySnapshot, @@ -285,6 +216,16 @@ pub struct Download { pub install: Option, /// 快照里有没有版本列表(`false` = 还没拉到 → 加载态) pub loaded: bool, + /// 搜索词(presenter 的 `download_query` 原样;空串 = 不过滤) + pub query: String, + /// **跨分类**命中数:搜索时侧栏显示它,所以它不随当前分类变化 + pub hits: usize, + /// 选中的主版本桶(`None` = 全部;第二级侧栏的选中态,纯视图态) + pub selected_major: Option, + /// 类型栏被用户手动折叠(`true` = 64 图标条;宽度档自己也会折叠,两者取更窄的) + pub type_rail_folded: bool, + /// 主版本桶(第二级侧栏的数据源;由 [`Self::rows`] 聚合,所以只含**当前分类**的桶) + pub buckets: Vec, } impl Download { @@ -309,6 +250,9 @@ impl Download { /// 它住在 `App`(照分类筛选的既有做法),因为 `refresh_view` 每次事件都会把整棵模型重建, /// 页面自己存不住任何东西。选中项还要**在当前列表里**才算数:切分类后那一行不在了, /// 就不该还留着一个点不动的「安装」。 + /// + /// M107:`query` / `hits` / `buckets` 也走这条路——查询词来自快照的 `download_query`, + /// 桶由过滤后的行聚合(第二级侧栏的数据源)。 pub fn from_snapshot( state: &AppState, category: Category, @@ -316,12 +260,22 @@ impl Download { catalog: Catalog, ) -> Self { let query = state.download_query.trim().to_ascii_lowercase(); + // 过滤谓词写一次、两处用:`hits` 是**跨分类**命中数(搜索框旁边的「N 个结果」不能 + // 随当前分类变,否则用户在「正式版」里搜一个只有快照才有的串会看到 0)。 + let matches = |version: &VersionSummary| { + (!state.installed_only || version.installed) + && (query.is_empty() || version.id.to_ascii_lowercase().contains(&query)) + }; + + let hits = state + .versions + .iter() + .filter(|version| matches(version)) + .count(); let rows = state .versions .iter() - .filter(|version| category.matches(version.kind)) - .filter(|version| !state.installed_only || version.installed) - .filter(|version| query.is_empty() || version.id.to_ascii_lowercase().contains(&query)) + .filter(|version| matches(version) && category.matches(version.kind)) .map(|version| VersionRow::of(version, catalog)) .collect::>(); @@ -354,6 +308,8 @@ impl Download { }; Self { + // 桶由**过滤后的行**聚合:第二级侧栏只列当前分类里真实出现的版本族。 + buckets: super::group::buckets(&rows), rows, // 分类不再从列表反推(那正是「chip 跳回全部」的来源):谁选的就是谁。 // [`Category::of`] 只留给「不经过用户选择」的入口(演示数据的首帧)。 @@ -362,6 +318,13 @@ impl Download { options, install, loaded: !state.versions.is_empty(), + // 搜索词与命中数都来自快照:页面不持有查询(见模块文档「为什么搜索也走快照」)。 + query: state.download_query.trim().to_owned(), + hits, + // 主版本桶的选中是**纯视图态**(同 `selected`):快照重建会回到「全部」。 + selected_major: None, + // 折叠也是纯视图态;搜索重建时由 `rebuild_after_query` 前后各搬一次。 + type_rail_folded: false, } } @@ -380,6 +343,24 @@ impl Download { pub fn is_empty(&self) -> bool { self.loaded && self.rows.is_empty() } + + /// 有搜索词(空串不算:用户在框里敲了空格不该触发「没有匹配」)。 + pub fn has_query(&self) -> bool { + !self.query.is_empty() + } + + /// **整份清单**里一个都不匹配(跨分类也没有)⇒ 画「没有匹配」而不是白屏。 + pub fn no_match(&self) -> bool { + self.loaded && self.has_query() && self.hits == 0 + } + + /// 有命中、但**不在当前分类**里 ⇒ 画「N 个版本在别的类型下」,并给一个「看全部」的出口。 + /// + /// 这条与 [`Self::no_match`] 分开,是因为两者该说的话完全不同:一个是「真的没有」, + /// 一个是「有,但你站错了分类」——后者让用户多点一次 tab 就白跑一趟。 + pub fn only_elsewhere(&self) -> bool { + self.loaded && self.rows.is_empty() && self.hits > 0 + } } #[cfg(test)] diff --git a/crates/app-iced/src/pages/download/model/category.rs b/crates/app-iced/src/pages/download/model/category.rs new file mode 100644 index 0000000..eab9afa --- /dev/null +++ b/crates/app-iced/src/pages/download/model/category.rs @@ -0,0 +1,85 @@ +//! 分类筛选(原顶部四个 chip / 现在是第一级侧栏的四个固定项)。 +//! +//! 从 `model.rs` 搬出来:M105/M107 合流后那边到 441 行(超 400 硬线)。 +//! **纯搬移**:枚举、`matches` 谓词、`command()` 与 `of()` 一字未改, +//! 路径仍由 `model.rs` 的 `pub use` 保持 `pages::download::Category`。 + +use unlucky_i18n::Key; +use unlucky_presenter::dto::{AncientKind, VersionCategory, VersionKind, VersionSummary}; + +/// 分类筛选(页面上的四个 filter chip)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Category { + /// 不过滤:画快照里有什么就是什么(首帧与 `RefreshAll` 的落点) + #[default] + All, + /// 正式版 + Release, + /// 预览版 + Snapshot, + /// 远古版(快照里按 `AncientKind` 分四类,页面先只认一个 chip) + Ancient, +} + +impl Category { + /// 四个 chip 的声明顺序(= 画出来的顺序)。 + pub const ALL: [Category; 4] = [ + Category::All, + Category::Release, + Category::Snapshot, + Category::Ancient, + ]; + + /// chip 文案的 key。 + pub const fn label_key(self) -> Key { + match self { + Category::All => Key::DownloadCategoryAll, + Category::Release => Key::DownloadCategoryRelease, + Category::Snapshot => Key::DownloadCategorySnapshot, + Category::Ancient => Key::DownloadCategoryAncient, + } + } + + /// 点这个 chip 要派的指令(`None` = 不派:`All` 就是「把当前快照全画出来」)。 + /// + /// 远古版在 `VersionCategory` 里是**带子类的**(`Ancient(AncientKind)`),一个 chip 拉不全四类; + /// 这里先拉 `Classic`,四子类同屏要靠 presenter 侧支持(已登记 finding)。 + pub const fn command(self) -> Option { + match self { + Category::All => None, + Category::Release => Some(VersionCategory::Release), + Category::Snapshot => Some(VersionCategory::Snapshot), + Category::Ancient => Some(VersionCategory::Ancient(AncientKind::Classic)), + } + } + + /// 这个版本属于这个分类吗。 + pub fn matches(self, kind: VersionKind) -> bool { + match self { + Category::All => true, + Category::Release => kind == VersionKind::Release, + Category::Snapshot => kind == VersionKind::Snapshot, + Category::Ancient => kind.is_ancient(), + } + } + + /// 从快照里**反推**当前分类:整份列表只有一类 → 那一类;混合或为空 → [`Category::All`]。 + /// + /// 快照没有「我上次按哪个分类拉的」这种字段,所以分类靠列表内容反推 + /// (`ListVersions` 的结果是单一分类,`RefreshAll` 的结果是混合)。 + pub fn of(versions: &[VersionSummary]) -> Self { + let mut kinds = versions.iter().map(|version| version.kind); + let Some(first) = kinds.next() else { + return Category::All; + }; + if kinds.all(|kind| kind == first) { + match first { + VersionKind::Release => Category::Release, + VersionKind::Snapshot => Category::Snapshot, + VersionKind::Ancient(_) => Category::Ancient, + } + } else { + Category::All + } + } +} diff --git a/crates/app-iced/src/pages/download/rails.rs b/crates/app-iced/src/pages/download/rails.rs new file mode 100644 index 0000000..3b917e8 --- /dev/null +++ b/crates/app-iced/src/pages/download/rails.rs @@ -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> { + 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() +} diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs index 3b76baf..0928cb6 100644 --- a/crates/app-iced/src/pages/download/rows.rs +++ b/crates/app-iced/src/pages/download/rows.rs @@ -1,23 +1,39 @@ -//! 版本列表:分类 Tabs + 计数头 + `ListItem` 行(全是 `components/` 的真组件)。 +//! 列表列:搜索条 + 计数头 + **单行**版本行 + 底部安装面板。 //! -//! | 位置 | M84(占位) | M94(真组件) | +//! ## 这一列的职责边界 +//! +//! | 位置 | 画什么 | 消息 | //! |---|---|---| -//! | 分类 | 无(整页只有一行假状态) | [`navigation::tabs`] 的 Primary 档(48 高、3px `Primary` 指示条) | -//! | 列表行 | 无 | [`navigation::list_item`] 的两行档(**60** = `row_height` 44 + 每多一行 16) | -//! | 计数头 | 无 | `LabelMedium` + `OnSurfaceVariant`(与实例页列表头同一个语言) | -//! | 已装标记 | 无 | overline `Installed` + trailing `Check`(`Primary`) | +//! | 搜索条 | 真实控件的 `search_bar`(56 高、药丸)+ 清空按钮 + 命中数 | [`Message::SetVersionQuery`] / [`Message::ClearVersionQuery`] | +//! | 计数头 | `{count} versions`(`LabelMedium` + `OnSurfaceVariant`,与实例页列表头同一个语言) | 无 | +//! | 列表 | `ListItem` + 三列短文本 + 尾随图标按钮 | [`Message::SelectVersion`] / [`Message::InstallVersion`] | +//! | 面板 | 见 [`super::install`](M105 的安装选项区**不在这里**) | — | //! -//! 行高从「手搓 56」变成 60 的理由与实例页完全一样:真 `ListItem` 自己的几何真源,页面不覆盖。 +//! ## 版本行为什么必须是**单行** //! -//! ## 行距归零(M102) +//! 任务硬约束:列表列最窄 424(行宽下限),此时「版本号 + 类型 + 日期 + 安装按钮」四样仍要在 +//! 一行里。所以:版本号那一格是 `ListItem`(它自己的 `Wrapping::None` 保证不折行,超长由外层 +//! clip 切掉),类型 / 日期 / 安装按钮三格是**定宽**([`super::super::layout::rails`] 的音阶)。 +//! 折行的唯一可能来源是定宽格子挤爆容器——那条由列表列下限 424 挡住(`layout/rails/tests.rs` +//! 的扫描断言)。 //! -//! 原先页面在 `ListItem` 的 60/76 高之外又加了 `Space::X1`(4)的行距,行节奏因此与可视高度 -//! 除不尽——实例页复现成「末行被卡片底边切半行」(视觉复审 ②)。两页一起归零:行距只由组件的 -//! 几何真源决定(`ListItem` 自己的上下 8px 内边距已经够了),`Space` 里也没有 0 这一档, -//! 所以 `list()` 里**不写** `.spacing(…)`。 +//! ## 搜索的三条口径 +//! +//! | 情形 | 画什么 | 为什么 | +//! |---|---|---| +//! | 有命中 | 列表照常,侧栏**位置不动**,搜索条右侧显示「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, scrollable}; -use iced::{Element, Length, Padding}; +use iced::widget::{column, container, row, scrollable}; +use iced::{Alignment, Element, Length, Padding}; use unlucky_design::color::Role; use unlucky_design::icons::IconKind; @@ -26,65 +42,141 @@ 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::app::components::navigation::tabs::{tabs, Tab, Tabs}; 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::{Category, Download, VersionRow}; +use super::model::{Download, VersionRow}; +use super::{install, states}; -/// 分类筛选(Primary Tabs:选中项下方 3px `Primary` 指示条)。 -/// -/// 点一个分类 = `Message::SetCategory`,由 app 层翻成 `AppCommand::ListVersions`—— -/// 页面**不**自己过滤列表(列表内容 = 快照里有什么),所以「点了但没反应」只会发生在 -/// presenter 没回列表的时候,不会出现「页面过滤过、后端还是旧分类」的两份真相。 -pub(super) fn categories<'a>( +/// 列表列(三栏档与 chips 档共用一份)。 +pub(super) fn content<'a>( ui: &'a Ui, catalog: Catalog, - current: Category, + model: &'a Download, ) -> Element<'a, Message> { - let mut row = Tabs::primary(); - for category in Category::ALL { - let mut tab = Tab::new( - catalog.text(category.label_key()), - Message::SetCategory(category), - ); - if category == current { - tab = tab.selected(); - } - row = row.tab(tab); + 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, + )); } - tabs(ui, row) + + line.into() } /// 计数头(`LabelMedium`):一行 = 一个 key,数字走占位符,不拼片段。 -pub(super) fn head<'a>(ui: &'a Ui, catalog: Catalog, model: &Download) -> Element<'a, Message> { +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", &model.rows.len().to_string())], - ), + catalog.render(Key::DownloadCount, &[("count", &count.to_string())]), ui.color(Role::OnSurfaceVariant), ) } -/// 版本行列表(**独立滚动**:只有列表滚,安装面板钉在底部不跟着滚)。 -pub(super) fn list<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> { - let rows = column( - model +/// 当前可见的行:先按分类(模型已过滤),再按第二级侧栏选中的主版本桶。 +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(rows) + scrollable(entries) .direction(scrollable::Direction::Vertical( scrollable::Scrollbar::new() .width(frame::SCROLLBAR_WIDTH) @@ -96,11 +188,10 @@ pub(super) fn list<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Ele .into() } -/// 一行:headline = 版本 id,supporting = `正式版 · 发布于 2026-09-20`,已装给勾。 +/// 一行:`ListItem`(版本号,可点选)+ 类型 + 日期 + 安装图标按钮(**四样同一行**)。 /// -/// 点一行 = `Message::SelectVersion(id)`(**页面内选中**,不 dispatch);「安装」是旁边那个 -/// 独立按钮,点它才派 `AppCommand::InstallVersion`。选中不做乐观回显:行的高亮来自模型, -/// 模型在 app 层被改写之后才变。 +/// 点版本号 = [`Message::SelectVersion`](页面内选中,不 dispatch);点尾随按钮 = +/// [`Message::InstallVersion`]。已装的版本给勾 + 禁用按钮(不重复安装)。 fn version_row<'a>( ui: &'a Ui, catalog: Catalog, @@ -108,17 +199,70 @@ fn version_row<'a>( entry: &'a VersionRow, ) -> Element<'a, Message> { let mut item = ListItem::new(entry.id.as_str(), Message::SelectVersion(entry.id.clone())) - .supporting(entry.supporting.as_str()) .leading(IconKind::Download); if entry.installed { - item = item - .overline(catalog.text(Key::DownloadInstalled)) - .trailing(IconKind::Check); + item = item.trailing(IconKind::Check); } if model.selected.as_deref() == Some(entry.id.as_str()) { item = item.selected(); } - list_item(ui, item) + // 版本号那一格吃掉剩下的宽(`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() } diff --git a/crates/app-iced/src/pages/download/rows/tests.rs b/crates/app-iced/src/pages/download/rows/tests.rs new file mode 100644 index 0000000..e05ab31 --- /dev/null +++ b/crates/app-iced/src/pages/download/rows/tests.rs @@ -0,0 +1,41 @@ +//! 列表列的两条判据:**单行**(四样挤得下)与**搜索框真的在**(不是摆样子)。 + +use crate::pages::layout::rails::{ + version_list_min_width, version_row_min_width, LIST_DATE_WIDTH, LIST_KIND_WIDTH, ROW_GAP, + ROW_ICON_BUTTON, +}; + +/// 行宽下限 424 之下,四样东西仍在一行里(并留出够放 `1.21.9-pre3` 的版本号格)。 +/// +/// 抓得住:有人把类型 / 日期 / 按钮的格子改宽(改到 424 放不下时这条红),或者把行宽下限调小。 +/// 抓不住:真的渲染出来会不会折行(`ListItem` 的 `Wrapping::None` 是组件层的事,成图在报告里)。 +#[test] +fn the_version_row_still_fits_on_one_line_at_the_floor() { + let floor = version_list_min_width(); + assert_eq!(floor, 424.0, "行宽下限就是既有的 424"); + + let needed = version_row_min_width(); + assert!( + needed <= floor, + "四样固定格加起来要 {needed},超过行宽下限 {floor} 就得折行" + ); + + // 版本号那一格在最窄情况下还剩多少 + let label_room = floor - LIST_KIND_WIDTH - LIST_DATE_WIDTH - ROW_ICON_BUTTON - 3.0 * ROW_GAP.dp(); + assert!( + label_room >= 176.0, + "版本号那一格只剩 {label_room},`1.21.9-pre3`(11 字符)会被切" + ); +} + +/// 搜索条出现在列表列里(源码层面钉住:「有搜索框」这件事不能悄悄被删掉)。 +/// +/// 抓得住:有人把搜索条从列表列里拿掉(那一整块需求就没了)。 +/// 抓不住:搜索条是否**生效**(效果由 `download/tests.rs` 的跨类型命中数断言与出图负责)。 +#[test] +fn the_search_bar_is_actually_mounted() { + let source = include_str!("../rows.rs"); + assert!(source.contains("search_bar("), "搜索条没装"); + assert!(source.contains("Message::SetVersionQuery"), "搜索条不产生查询消息"); + assert!(source.contains("Message::ClearVersionQuery"), "搜索条没有清空入口"); +} diff --git a/crates/app-iced/src/pages/download/states.rs b/crates/app-iced/src/pages/download/states.rs index d890787..d0f09fb 100644 --- a/crates/app-iced/src/pages/download/states.rs +++ b/crates/app-iced/src/pages/download/states.rs @@ -33,11 +33,17 @@ use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key}; +use crate::app::components::buttons::{self, Button}; use crate::app::components::feedback::{empty_state, EmptyState}; use crate::theme::Ui; use super::super::frame; use super::super::{Action, Message}; +use super::model::Category; + +/// 空态图标块边长(12b §15.1:**96×96**,与 `components::feedback::EmptyState` 内部同一个项目值; +/// 那里是 `frame::pieces::EMPTY_ICON_BLOCK`(`pub(super)`,本模块取不到,只能同值声明一次)。 +const EMPTY_ICON_BLOCK: f32 = 96.0; /// 两个占位状态共用的外壳:水平 + 垂直都居中(见模块文档的两条理由)。 fn centered<'a>(content: impl Into>) -> Element<'a, Message> { @@ -89,3 +95,67 @@ pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { ), )) } + +/// 搜索**一个都不命中**(跨类型也没有):说「没有匹配」+ 一句「换短一点的片段」+ 清空入口。 +/// +/// 与 [`empty`] 分开的理由:那个是「这个分类里没有版本」,这个是「这个词没有任何版本」—— +/// 两种情况该给的下一步动作不同(前者换分类/刷新,后者改搜索词)。 +pub(super) fn no_match<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { + centered(empty_state( + ui, + EmptyState::new( + IconKind::Search, + catalog.text(Key::VersionSearchEmptyTitle), + catalog.text(Key::VersionSearchEmptyBody), + ) + .action( + catalog.text(Key::VersionSearchClear), + Message::ClearVersionQuery, + ), + )) +} + +/// **有命中但不在当前分类**:说清「N 个在别的类型里」+ 一个「看全部类型」的出口。 +/// +/// 不给出口的话,用户只能自己猜「是不是要切到快照 tab」——这正是本条的验收口径。 +pub(super) fn elsewhere<'a>(ui: &'a Ui, catalog: Catalog, hits: usize) -> Element<'a, Message> { + let count = hits.to_string(); + let title = catalog.render(Key::VersionSearchHits, &[("count", count.as_str())]); + let body = catalog.render(Key::VersionSearchElsewhere, &[("count", count.as_str())]); + + // 手搭而不是用 `EmptyState`:它的标题/正文借 `&'a str`,而这里的句子是 `render()` 出来的 + // `String`(带命中数),借不到 `'a`。字形、字级、按钮全走同一套令牌,视觉上仍是空态。 + centered( + column![ + frame::glyph_box( + IconKind::Search, + ui.color(Role::OnSurfaceVariant), + EMPTY_ICON_BLOCK + ), + frame::text_role_owned( + ui, + TypeRole::HeadlineSmall, + false, + title, + ui.color(Role::OnSurface), + ), + frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + body, + ui.color(Role::OnSurfaceVariant), + ), + buttons::filled( + ui, + Button::new( + catalog.text(Key::VersionSearchElsewhereAction), + Message::SetCategory(Category::All), + ), + ), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_x(Alignment::Center), + ) +} diff --git a/crates/app-iced/src/pages/download/tests.rs b/crates/app-iced/src/pages/download/tests.rs index 978cac3..b2dde68 100644 --- a/crates/app-iced/src/pages/download/tests.rs +++ b/crates/app-iced/src/pages/download/tests.rs @@ -1,7 +1,7 @@ -//! 下载页的判据:**快照 → 模型 → 控件树**三段各自的断言。 +//! 版本管理页的判据:**快照 → 模型 → 控件树**三段各自的断言。 //! -//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯转换与树能构建,**像素**由报告里的 -//! 四张图与数值表负责(同 `pages/tests.rs` 的口径)。 +//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯转换与树能构建,**像素**由 +//! `DevDocs/artifacts/m107/` 的十张图与报告的量测表负责(同 `pages/tests.rs` 的口径)。 use unlucky_design::color::SchemeFlavor; use unlucky_design::typography::Platform; @@ -14,8 +14,7 @@ use unlucky_presenter::state::AppState; use crate::theme::{Families, Ui}; -use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit}; -use super::super::Message; +use super::super::layout::{frame_metrics, measure_cap_px, PaneKit}; use super::model::{Category, Download}; use super::options::InstallDraft; @@ -46,19 +45,32 @@ fn state_with(versions: Vec) -> AppState { state } -// --------------------------------------------------------------- 版面(M84 的三条,M94 一条没动) +/// 一份覆盖四种版本形态的清单(`1.21.x` / `24w14a` / `rd-132211` / `a1.2.6`)。 +fn mixed() -> Vec { + vec![ + version("1.21.9", VersionKind::Release, false), + version("1.21.8", VersionKind::Release, true), + version("1.20.4", VersionKind::Release, true), + version("24w14a", VersionKind::Snapshot, false), + version( + "rd-132211", + VersionKind::Ancient(AncientKind::Classic), + false, + ), + ] +} -/// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。 +// --------------------------------------------------------------- 版面 + +/// 页面仍是单列页(页内三栏是**页内**的事,不改 `PaneKit`):任何宽度都不出现「页面级第二列」。 /// -/// 抓得住:`layout` 把下载页切到两列、或把正文列放过 424。 -/// 抓不住:页面内部又套了一层更宽的容器(那要看真像素,见报告的量测表)。 +/// 抓得住:有人把 `Screen::Download` 的 `kit()` 改成 `ListDetail`(那会让页头与页边距跟着变)。 +/// 抓不住:页内三栏的宽度(那由 `layout/rails/tests.rs` 的数值表钉住)。 #[test] -fn download_is_single_column_at_every_width() { - for width in [600.0, 840.0, 900.0, 1180.0, 2400.0] { +fn the_page_kit_is_still_single() { + for width in [700.0, 900.0, 1024.0, 1180.0, 2400.0] { let metrics = frame_metrics(PaneKit::Single, width); - assert!(metrics.secondary.is_none(), "{width} 宽下载页出现了第二列"); - assert!(metrics.column <= measure_cap_px() + f32::EPSILON); - assert_eq!(metrics.column, content_column_width(width)); + assert!(metrics.secondary.is_none(), "{width} 出现了页面级第二列"); } } @@ -81,10 +93,15 @@ fn extra_width_goes_to_the_page_block_not_to_the_prose_column() { ); } +// --------------------------------------------------------------- 两层侧栏(见 `tests/rails.rs`) + +mod rails; + +// --------------------------------------------------------------- 文案(i18n) + // --------------------------------------------------------------- 快照 → 模型 -/// 真快照进得来:行数、分类、选中项、已装标记全部来自 `AppState`。 -/// 抓不住:快照字段在 `app/view.rs` 被漏接(那由 `app/update.rs` 的接线测试兜)。 +/// 真快照进得来:行数、分类、选中项、已装标记、主版本桶全部来自 `AppState`。 #[test] fn the_snapshot_becomes_the_page_model() { let mut state = state_with(vec![ @@ -114,32 +131,23 @@ fn the_snapshot_becomes_the_page_model() { assert_eq!(model.rows.len(), 2); assert_eq!(model.rows[0].id, "1.21.9"); assert!(model.rows[1].installed, "快照说装了就是装了"); - assert!(!model.rows[0].installed); - assert_eq!( - model.category, - Category::All, - "分类是**调用方给的过滤条件**,不再从列表反推(反推正是 chip 跳回「全部」的来源)" - ); + assert_eq!(model.category, Category::All); assert_eq!( model.selected.as_deref(), Some("1.21.9"), "正在安装的版本 = 选中行" ); assert!(model.loaded, "有版本 = 已加载"); + assert_eq!(model.buckets.len(), 1, "两个版本同一个主版本桶"); + assert_eq!(model.buckets[0].key.as_deref(), Some("1.21")); + assert_eq!(model.hits, 2, "没有搜索词时命中数 = 行数"); let install = model.install.expect("安装进度要转进来"); - assert!(install.running(), "Downloading 还没收尾"); - assert!(!install.failed()); + assert!(install.running()); assert_eq!(install.detail, "128/512 files"); - assert_eq!( - install.rate.as_deref(), - Some("1.2 MB/s · 1m15s left"), - "速率与剩余时间走 key,不搬 core 的中文串" - ); } /// 快照里的两个过滤条件(`installed_only` / `download_query`)原样照用。 -/// 抓不住:过滤词大小写之外的语言学规则(那属于 presenter 的搜索口径)。 #[test] fn presenter_filters_still_apply() { let mut state = state_with(vec![ @@ -170,11 +178,11 @@ fn presenter_filters_still_apply() { ) }; assert_eq!(queried.rows.len(), 2, "版本号片段匹配两个"); - assert!(queried.rows.iter().all(|row| row.id.starts_with("1.21"))); + assert_eq!(queried.query, "1.21", "查询词去掉首尾空格后进模型"); + assert_eq!(queried.hits, 2); } -/// 空快照 = 加载态;过滤掉所有行 = 空态(抓「把还没拉到画成没有版本」,或反过来)。 -/// 抓不住:真机上卡在加载态多久(没有超时信号,见模块文档的缺口表)。 +/// 空快照 = 加载态;过滤掉所有行 = 空态。 #[test] fn loading_and_empty_are_different_states() { let loading = Download::from_snapshot( @@ -195,6 +203,73 @@ fn loading_and_empty_are_different_states() { ); assert!(!empty.is_loading(), "列表拉到了,不是加载中"); assert!(empty.is_empty(), "过滤后没有行 = 空态"); + assert!(empty.no_match(), "跨类型也没有 ⇒ 没有匹配"); + assert!(!empty.only_elsewhere()); +} + +/// **搜索跨类型**:命中数与当前分类无关,行数相关。 +/// +/// 抓得住:有人把 `hits` 也算成「当前分类的命中」(那会让「正式版里搜 24w」显示 0 个匹配, +/// 用户以为没有这个版本)。 +/// 抓不住:大小写之外的搜索口径(模糊匹配 / 分词属 presenter)。 +#[test] +fn search_hits_are_cross_category() { + let mut state = state_with(mixed()); + state.download_query = "1.20".to_string(); + + let all = Download::from_snapshot( + &state, + Category::All, + &InstallDraft::default(), + Lang::En.catalog(), + ); + assert_eq!(all.rows.len(), 1); + assert_eq!(all.hits, 1); + + // 同一个词,站在「预览版」分类里:行数 0,命中数仍是 1 + let snapshot = Download::from_snapshot( + &state, + Category::Snapshot, + &InstallDraft::default(), + Lang::En.catalog(), + ); + assert_eq!(snapshot.rows.len(), 0); + assert_eq!(snapshot.hits, 1, "命中数是跨类型读数"); + assert!(snapshot.only_elsewhere(), "有命中但不在当前分类"); + assert!(!snapshot.no_match(), "不是「没有匹配」"); +} + +/// 「搜不到」与「在当前分类搜不到」是两种画面(文案与出口都不同)。 +#[test] +fn the_two_search_misses_are_distinguishable() { + let mut state = state_with(mixed()); + + state.download_query = "zzz".to_string(); + let nothing = Download::from_snapshot( + &state, + Category::All, + &InstallDraft::default(), + Lang::En.catalog(), + ); + assert!(nothing.no_match() && !nothing.only_elsewhere()); + + state.download_query = "rd-".to_string(); + let elsewhere = Download::from_snapshot( + &state, + Category::Release, + &InstallDraft::default(), + Lang::En.catalog(), + ); + assert!(elsewhere.only_elsewhere() && !elsewhere.no_match()); + + let home = Download::from_snapshot( + &state, + Category::Ancient, + &InstallDraft::default(), + Lang::En.catalog(), + ); + assert_eq!(home.rows.len(), 1, "切到远古版就看见了"); + assert!(!home.no_match() && !home.only_elsewhere()); } /// 分类反推:混合 = 全部,远古 = 远古(不看子类)。 @@ -220,10 +295,7 @@ fn the_category_is_derived_from_the_list() { assert_eq!(Category::of(&[]), Category::All); } -/// 分类 chip 派的是**真指令**:`VersionCategory` 直接喂给 `AppCommand::ListVersions`。 -/// -/// 抓得住:chip 点到不存在的指令、或把 `All` 也派出去(那会白拉一次列表)。 -/// 抓不住:后端是否真按分类回列表(presenter/host 的测试负责)。 +/// 分类 chip 派的是**真指令**。 #[test] fn category_commands_are_real() { assert_eq!(Category::All.command(), None, "全部 = 画现有快照,不派指令"); @@ -234,112 +306,12 @@ fn category_commands_are_real() { ); assert_eq!( Category::Ancient.command(), - Some(VersionCategory::Ancient(AncientKind::Classic)), - "远古版先拉 classic(四子类同屏要 presenter 支持,已登记 finding)" + Some(VersionCategory::Ancient(AncientKind::Classic)) ); assert!(Category::Ancient.matches(VersionKind::Ancient(AncientKind::Unlisted))); assert!(!Category::Release.matches(VersionKind::Snapshot)); } -// --------------------------------------------------------------- 控件树 +// --------------------------------------------------------------- 控件树(见 `tests/view.rs`) -/// 五个状态都能构建出控件树(分支走到、不 panic、消息类型对得上)。 -/// -/// 抓得住:某个状态分支漏了返回值、或消息类型接错(编译期就红);空态/加载态忘了给内容。 -/// 抓不住:**画出来长什么样**——像素由报告里的四张图负责,这条只保证「有树」。 -#[test] -fn every_state_builds_a_tree() { - let ui = ui(); - let metrics = frame_metrics(PaneKit::Single, 1180.0); - let catalog = Lang::En.catalog(); - - let mut loading = Download::loading(); - loading.selected = None; - - let empty = Download { - loaded: true, - ..Download::loading() - }; - - let ready = Download { - options: None, - rows: vec![super::model::VersionRow::of( - &version("1.21.9", VersionKind::Release, false), - catalog, - )], - category: Category::Release, - selected: Some("1.21.9".to_string()), - install: None, - loaded: true, - }; - - let installing = Download { - install: Some(super::model::InstallView::of( - &InstallProgressView { - version: "1.21.9".to_string(), - stage: InstallStage::Downloading, - files_done: 128, - files_total: 512, - ..InstallProgressView::new() - }, - catalog, - )), - ..ready.clone() - }; - - let failed = Download { - install: Some(super::model::InstallView::of( - &InstallProgressView { - version: "1.21.9".to_string(), - stage: InstallStage::Failed, - ..InstallProgressView::new() - }, - catalog, - )), - ..ready.clone() - }; - - for model in [loading, empty, ready, installing, failed] { - let _: iced::Element<'_, Message> = super::view(&ui, metrics, &model, Lang::En); - } -} - -/// 安装失败的模型:可读文案 + 出口都有(不静默)。 -/// -/// 抓得住:失败态被画成「已完成」或什么都不给。 -/// 抓不住:失败原因的具体内容(core 只给中文 `message`,页面按规矩不画它,见模块文档)。 -#[test] -fn a_failed_install_stays_visible() { - let model = super::model::InstallView::of( - &InstallProgressView { - version: "1.21.9".to_string(), - stage: InstallStage::Failed, - ..InstallProgressView::new() - }, - Lang::En.catalog(), - ); - assert!(model.failed()); - assert!(model.terminal()); - assert!(!model.running(), "失败之后不再给「取消安装」"); - assert_eq!(model.stage_label, "Install failed"); -} - -/// 演示数据自己也得守规矩(它是出图的基线):抓中文字面量混入 / 行数与分类对不上。 -#[test] -fn the_demo_data_is_sane() { - let catalog = Lang::En.catalog(); - let demo = super::demo(catalog); - assert!(demo.loaded); - assert_eq!(demo.rows.len(), 6); - assert_eq!(demo.category, Category::All, "混合列表没有单个分类该亮"); - assert!( - demo.rows - .iter() - .all(|row| row.supporting.contains("released")), - "英文目录下的副文案走英文 key" - ); - assert!( - demo.rows.iter().all(|row| !row.supporting.contains('版')), - "英文目录下不许出现中文类型词" - ); -} +mod view; diff --git a/crates/app-iced/src/pages/download/tests/i18n.rs b/crates/app-iced/src/pages/download/tests/i18n.rs index 4aee0c0..b5fa3ab 100644 --- a/crates/app-iced/src/pages/download/tests/i18n.rs +++ b/crates/app-iced/src/pages/download/tests/i18n.rs @@ -17,10 +17,15 @@ use super::*; /// 它只看字面出现,用宏拼 key 名能骗过它。 #[test] fn every_download_key_is_referenced_by_the_page() { - const SOURCES: [&str; 8] = [ + const SOURCES: [&str; 11] = [ include_str!("../../download.rs"), include_str!("../model.rs"), + // M107:分类枚举搬进 `model/category.rs`(`Category::label_key` 在那儿,漏扫会误判死 key) + include_str!("../model/category.rs"), include_str!("../rows.rs"), + // M107:两级侧栏与分组规则(`VersionRailTitle` / `VersionMajorAll` 的落点) + include_str!("../rails.rs"), + include_str!("../group.rs"), include_str!("../install.rs"), include_str!("../states.rs"), include_str!("../demo.rs"), @@ -32,7 +37,7 @@ fn every_download_key_is_referenced_by_the_page() { let mut checked = 0; for key in Key::ALL .iter() - .filter(|key| key.name().starts_with("Download")) + .filter(|key| key.name().starts_with("Download") || key.name().starts_with("Version")) { let needle = format!("Key::{}", key.name()); assert!( @@ -45,6 +50,10 @@ fn every_download_key_is_referenced_by_the_page() { checked > 20, "下载页的 key 不该被成批删掉(只查到 {checked} 条)" ); + assert!( + checked > 50, + "M107 之后这一页的 key(Download* + Version*)不该少于 50 条,只查到 {checked}" + ); } /// 页面级的文案全部按 `Catalog` 走:中文目录出中文,英文目录出英文,**且不含 core 的中文业务串**。 diff --git a/crates/app-iced/src/pages/download/tests/rails.rs b/crates/app-iced/src/pages/download/tests/rails.rs new file mode 100644 index 0000000..a6ae060 --- /dev/null +++ b/crates/app-iced/src/pages/download/tests/rails.rs @@ -0,0 +1,81 @@ +//! 两层侧栏的护栏(**变异可证**)+ 三栏宽度账。 +//! +//! 从 `download/tests.rs` 拆出来:那边已经贴着 400 行硬线。 + +use crate::pages::download::Category; +use crate::pages::layout::{available_width, VersionRailTier, VersionRails, RAIL_GAP}; + +/// 三栏要吃掉整条可用宽(不是 424 的居中正文列)——三个宽度档各取一个采样点。 +/// +/// 抓得住:有人把第三栏写成 `Length::Fill`(那会让三列宽度之和**超过**可用宽、右侧被挤出屏)。 +/// 抓不住:像素级对齐(出图在报告里)。 +#[test] +fn the_three_columns_spend_the_whole_available_width() { + for (window, tier) in [ + (1180.0, VersionRailTier::Wide), + (900.0, VersionRailTier::Narrow), + (700.0, VersionRailTier::Chips), + ] { + let rails = VersionRails::of(available_width(window)); + assert_eq!(rails.tier, tier, "{window} 的档位"); + if tier.is_three_column() { + let spent = rails.type_rail + rails.major_rail + rails.list + 2.0 * RAIL_GAP; + assert!( + (spent - available_width(window)).abs() < f32::EPSILON, + "{window}:三列 + 两处间距应正好等于可用宽({spent} vs {})", + available_width(window) + ); + } + } +} + +/// **两层侧栏都存在、且选中态真的传给列表**——M107 的护栏,改坏要红。 +/// +/// 四件事一起钉: +/// 1. 页面装配点仍调 `rails::three_column`(三栏装配是唯一入口); +/// 2. 装配点里**两个**侧栏函数都被调用(删掉 `major_rail(` → 只剩一级侧栏 ⇒ 红); +/// 3. 两级各自都有可点的消息(`SetCategory` / `SelectMajor`); +/// 4. 列表的行确实按第二级选中态过滤(`rows::visible` + `selected_major`)。 +/// +/// 抓得住:删掉一层侧栏、或者让列表无视 `selected_major`(第二级点了没反应)。 +/// 抓不住:两层是否**看起来**是两层(那要出图,见报告)。 +#[test] +fn both_rail_levels_exist_and_the_selection_reaches_the_list() { + let rails_source = include_str!("../rails.rs"); + let download_source = include_str!("../../download.rs"); + let rows_source = include_str!("../rows.rs"); + + assert!( + download_source.contains("rails::three_column"), + "页面装配点不再调三栏装配" + ); + for required in ["type_rail(ui", "major_rail(ui"] { + assert!( + rails_source.contains(required), + "两层侧栏缺了一级:找不到 {required}" + ); + } + assert!( + rails_source.contains("Message::SelectMajor"), + "第二级(主版本)没有可点的消息" + ); + assert!( + rails_source.contains("Message::SetCategory"), + "第一级(版本类型)没有可点的消息" + ); + assert!( + rows_source.contains("selected_major") && rows_source.contains("fn visible"), + "列表没有按第二级选中态过滤" + ); + assert_eq!(Category::ALL.len(), 4, "第一级还是原来那四类,不许新造分类"); +} + +/// `< 600` 可用宽时两栏都退场、列表吃掉可用宽(chips 档的账)。 +#[test] +fn the_chips_tier_gives_the_whole_width_to_the_list() { + let rails = VersionRails::of(564.0); + assert_eq!(rails.tier, VersionRailTier::Chips); + assert_eq!(rails.type_rail, 0.0); + assert_eq!(rails.major_rail, 0.0); + assert_eq!(rails.list, 564.0); +} diff --git a/crates/app-iced/src/pages/download/tests/view.rs b/crates/app-iced/src/pages/download/tests/view.rs new file mode 100644 index 0000000..e0e5c3e --- /dev/null +++ b/crates/app-iced/src/pages/download/tests/view.rs @@ -0,0 +1,147 @@ +//! 控件树那一节:九个状态 × 四档宽度都要能构图(M105/M107 合流后 `tests.rs` 到 433 行, +//! 按 AGENTS.md §3-1 拆出来)。 +//! +//! 只测「能构建出树」——**抓不住**画出来长什么样(像素由 `DevDocs/artifacts/m107/` 的图负责)。 + +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{InstallProgressView, InstallStage, VersionKind}; + +use crate::pages::download::model::{Download, InstallView, VersionRow}; +use crate::pages::download::{view, Category, Message}; +use crate::pages::layout::{frame_metrics, PaneKit}; + +use super::{ui, version}; + +/// 九个状态都能构建出控件树,且四档宽度都能构建(不 panic、消息类型对得上)。 +/// +/// 抓得住:某个状态分支漏了返回值、或消息类型接错(编译期就红);空态/加载态忘了给内容。 +/// 抓不住:**画出来长什么样**——像素由 `DevDocs/artifacts/m107/` 的图负责。 +#[test] +fn every_state_builds_a_tree_at_every_width() { + let ui = ui(); + let catalog = Lang::En.catalog(); + + let loading = Download::loading(); + let empty = Download { + loaded: true, + ..Download::loading() + }; + let ready = Download { + options: None, + rows: vec![VersionRow::of( + &version("1.21.9", VersionKind::Release, false), + catalog, + )], + category: Category::Release, + selected: Some("1.21.9".to_string()), + install: None, + loaded: true, + // M107 的五个新字段:桶 / 查询 / 命中数 / 第二级选中 / 折叠态 + ..Download::default() + }; + + let installing = Download { + install: Some(InstallView::of( + &InstallProgressView { + version: "1.21.9".to_string(), + stage: InstallStage::Downloading, + files_done: 128, + files_total: 512, + ..InstallProgressView::new() + }, + catalog, + )), + ..ready.clone() + }; + + let failed = Download { + install: Some(InstallView::of( + &InstallProgressView { + version: "1.21.9".to_string(), + stage: InstallStage::Failed, + ..InstallProgressView::new() + }, + catalog, + )), + ..ready.clone() + }; + + // M107:搜索 / 第二级选中 / 搜不到 / 手动折叠四个新增状态的取样 + let searching = Download { + query: "1.21".to_string(), + hits: 1, + ..ready.clone() + }; + let majored = Download { + selected_major: Some("1.21".to_string()), + ..ready.clone() + }; + let no_match = Download { + query: "zzz".to_string(), + hits: 0, + ..ready.clone() + }; + let folded = Download { + type_rail_folded: true, + ..ready.clone() + }; + + // 四档宽度:1180 / 1024(Wide 门槛)/ 900(Narrow)/ 700(Chips) + for width in [1180.0, 1024.0, 900.0, 700.0] { + let metrics = frame_metrics(PaneKit::Single, width); + for model in [ + loading.clone(), + empty.clone(), + ready.clone(), + installing.clone(), + failed.clone(), + searching.clone(), + majored.clone(), + no_match.clone(), + folded.clone(), + ] { + let _: iced::Element<'_, Message> = view(&ui, metrics, &model, Lang::En); + } + } +} + +/// 安装失败的模型:可读文案 + 出口都有(不静默)。 +#[test] +fn a_failed_install_stays_visible() { + let model = InstallView::of( + &InstallProgressView { + version: "1.21.9".to_string(), + stage: InstallStage::Failed, + ..InstallProgressView::new() + }, + Lang::En.catalog(), + ); + assert!(model.failed()); + assert!(model.terminal()); + assert!(!model.running(), "失败之后不再给「取消安装」"); + assert_eq!(model.stage_label, "Install failed"); +} + +/// 演示数据自己也得守规矩(它是出图的基线):八行、四个主版本桶、英文目录下不出现中文类型词。 +#[test] +fn the_demo_data_is_sane() { + let catalog = Lang::En.catalog(); + let demo = crate::pages::download::demo(catalog); + assert!(demo.loaded); + assert_eq!(demo.rows.len(), 8); + assert_eq!(demo.category, Category::All, "混合列表没有单个分类该亮"); + assert!(demo.query.is_empty(), "缺省演示没有搜索词"); + assert_eq!( + demo.buckets + .iter() + .map(|bucket| bucket.key.as_deref()) + .collect::>(), + vec![Some("24w"), Some("1.21"), Some("1.20"), None], + "第二级侧栏的桶顺序与内容" + ); + assert!(demo + .rows + .iter() + .all(|row| row.supporting.contains("released"))); + assert!(demo.rows.iter().all(|row| !row.supporting.contains('版'))); +} diff --git a/crates/app-iced/src/pages/layout.rs b/crates/app-iced/src/pages/layout.rs index dc4c089..4c55fed 100644 --- a/crates/app-iced/src/pages/layout.rs +++ b/crates/app-iced/src/pages/layout.rs @@ -385,5 +385,15 @@ pub fn frame_metrics(kit: PaneKit, window_width: f32) -> FrameMetrics { } } +/// 版本管理页的**页内**三栏几何(版本类型栏 / 主版本栏 / 列表列)。 +/// +/// 页面级版面只有三种([`PageLayout`]),而版本管理页要在单列里再切三栏——那次裁决住在这里, +/// 与 `PaneKit` 分开:`PaneKit` 决定「页面占多宽」,[`rails`] 决定「这宽度怎么切成三栏」。 +pub mod rails; + +pub use rails::{ + VersionRailTier, VersionRails, MAJOR_RAIL_WIDTH, RAIL_GAP, RAIL_ICON_WIDTH, TYPE_RAIL_WIDTH, +}; + #[cfg(test)] mod tests; diff --git a/crates/app-iced/src/pages/layout/rails.rs b/crates/app-iced/src/pages/layout/rails.rs new file mode 100644 index 0000000..951f1a2 --- /dev/null +++ b/crates/app-iced/src/pages/layout/rails.rs @@ -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; diff --git a/crates/app-iced/src/pages/layout/rails/tests.rs b/crates/app-iced/src/pages/layout/rails/tests.rs new file mode 100644 index 0000000..5c650f7 --- /dev/null +++ b/crates/app-iced/src/pages/layout/rails/tests.rs @@ -0,0 +1,139 @@ +//! 三栏几何的判据:**报告里的数值表就是这张表**,改任何一个门槛或栏宽都会在这里红。 +//! +//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯函数,**像素**由 `DevDocs/artifacts/m107/` +//! 的图与报告的量测表负责。 + +use super::*; +use crate::pages::layout::available_width; + +/// 报告里那张数值表的**逐行快照**(窗口 → 页面拿到多宽 → 可用宽 → 档 → 三列)。 +/// +/// 抓得住:任何一处门槛 / 栏宽 / 间距被改动(表里 7 行的档位或列宽立刻对不上)。 +/// 抓不住:改完之后画面**看起来**是否还行(那要出图,见报告)。 +#[test] +fn the_numeric_table_matches_the_report() { + // (页面拿到的宽, 期望档, 类型栏, 主版本栏, 列表) + let table: [(f32, VersionRailTier, f32, f32, f32); 7] = [ + (1180.0, VersionRailTier::Wide, 140.0, 140.0, 716.0), + (1132.0, VersionRailTier::Wide, 140.0, 140.0, 668.0), + (852.0, VersionRailTier::Narrow, 64.0, 140.0, 464.0), + (1024.0, VersionRailTier::Wide, 140.0, 140.0, 560.0), + (900.0, VersionRailTier::Narrow, 64.0, 140.0, 512.0), + (880.0, VersionRailTier::Narrow, 64.0, 140.0, 492.0), + (700.0, VersionRailTier::Chips, 0.0, 0.0, 564.0), + ]; + + for (page_width, tier, type_rail, major_rail, list) in table { + let rails = VersionRails::of(available_width(page_width)); + assert_eq!(rails.tier, tier, "{page_width} 的档位"); + assert_eq!(rails.type_rail, type_rail, "{page_width} 的类型栏"); + assert_eq!(rails.major_rail, major_rail, "{page_width} 的主版本栏"); + assert_eq!(rails.list, list, "{page_width} 的列表列"); + } +} + +/// 三栏档里,列表列**永远**不低于行宽下限 424(任务里那条硬约束:版本行不许折行)。 +/// +/// 抓得住:有人把门槛算小了(比如把 `Compact` 档门槛写成 560,列表会掉到 384 以下, +/// `1.21.9-pre3 · Snapshot · 2026-09-20 · Install` 这一行就得折)。 +/// 抓不住:折行到底发生在第几个字符(那要 FreeType 量宽 + 出图)。 +#[test] +fn every_three_column_tier_keeps_the_list_at_the_reading_floor() { + let floor = version_list_min_width(); + let mut sweep = 0; + // 4px 栅格扫一遍:从 480 到 2400 的可用宽 + let mut available = 480.0_f32; + while available <= 2400.0 { + let rails = VersionRails::of(available); + if rails.tier.is_three_column() { + assert!( + rails.list + f32::EPSILON >= floor, + "可用宽 {available}({} 档)的列表列 {} 低于行宽下限 {floor}", + rails.tier.name(), + rails.list + ); + sweep += 1; + } + available += 4.0; + } + assert!(sweep > 100, "扫描没跑到(只命中 {sweep} 档)"); +} + +/// 档位随可用宽**单调**下沉:变窄只可能降档,不会「窄一点反而三栏都展开」。 +#[test] +fn the_tiers_only_ever_step_down_as_the_width_shrinks() { + let order = |tier: VersionRailTier| match tier { + VersionRailTier::Wide => 0, + VersionRailTier::Narrow => 1, + VersionRailTier::Compact => 2, + VersionRailTier::Chips => 3, + }; + + let mut previous = VersionRails::of(4096.0); + let mut available = 4092.0_f32; + while available >= 200.0 { + let rails = VersionRails::of(available); + assert!( + order(rails.tier) >= order(previous.tier), + "{available} 反而比更宽时更「展开」:{:?} → {:?}", + previous.tier, + rails.tier + ); + previous = rails; + available -= 4.0; + } +} + +/// 三个门槛都是**算出来的**,不是拍的:把它们与文档里的公式对上。 +#[test] +fn the_three_thresholds_are_derived() { + assert_eq!(wide_min_available(), 888.0, "1024 窗宽换算的可用宽"); + assert_eq!( + narrow_min_available(), + 64.0 + 24.0 + 140.0 + 24.0 + 424.0, + "类型栏收成图标条后三栏仍成立的最小可用宽" + ); + assert_eq!( + compact_min_available(), + 64.0 + 24.0 + 64.0 + 24.0 + 424.0, + "两条栏都收成 64 时的最小可用宽" + ); + assert!(compact_min_available() < narrow_min_available()); + assert!(narrow_min_available() < wide_min_available()); + + // 门槛上的两个采样点:刚好到就进这一档,差 4px 就退一档 + assert_eq!( + VersionRails::of(narrow_min_available()).tier, + VersionRailTier::Narrow + ); + assert_eq!( + VersionRails::of(narrow_min_available() - 4.0).tier, + VersionRailTier::Compact + ); + assert_eq!( + VersionRails::of(compact_min_available()).tier, + VersionRailTier::Compact + ); + assert_eq!( + VersionRails::of(compact_min_available() - 4.0).tier, + VersionRailTier::Chips + ); +} + +/// 右栏展开(M104 的 `content_width`:窗口 − 320 − 拖拽缝 8)之后**仍是三栏**,且列表够宽。 +/// +/// 这条钉的是任务里那个场景:1180 窗宽开右栏,用户还能看见两条侧栏 + 一个不折行的版本列表。 +/// 抓得住:有人把 `Narrow` 门槛抬到 716 以上(那会让右栏一展开就掉进 chips)。 +/// 抓不住:`content_width` 自己算错(那是 M104 的测试)。 +#[test] +fn the_agent_rail_open_at_1180_keeps_three_columns() { + // M104 `content_width` = 窗口 − 右栗 320 − 拖拽缝 8 + let page_width = 1180.0 - 320.0 - 8.0; + let rails = VersionRails::of(available_width(page_width)); + + assert_eq!(page_width, 852.0); + assert_eq!(rails.tier, VersionRailTier::Narrow); + assert!(rails.tier.is_three_column(), "右栏展开不许把侧栏收掉"); + assert_eq!(rails.list, 464.0, "列表列 464(任务书写的 492 漏扣了 96)"); + assert!(rails.list >= version_list_min_width()); +} diff --git a/crates/app-iced/src/pages/message.rs b/crates/app-iced/src/pages/message.rs index 342c8a8..a8d0df4 100644 --- a/crates/app-iced/src/pages/message.rs +++ b/crates/app-iced/src/pages/message.rs @@ -51,6 +51,21 @@ pub enum Message { CancelInstall, /// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。 SetCategory(Category), + /// 搜索版本号(**M107**):查询词写进 `AppState.download_query`,页面走同一条重建路径过滤。 + /// + /// 为什么不是「页面自己存一份查询」:下载页是**无状态视图**(每帧由快照重建),页面持有的 + /// 字段活不过下一次事件;查询词只有落在 presenter 的快照里才能跨帧存活,而 + /// `Download::from_snapshot` 本来就读 `download_query` —— 复用这条路,过滤只有一份真相。 + SetVersionQuery(String), + /// 清空搜索框(**M107**)。与 [`Self::SetVersionQuery`] 同一支的 `""` 特例, + /// 单独一个变体是为了让「清空」这个意图在日志与测试里一眼可辨。 + ClearVersionQuery, + /// 选中第二级侧栏的一个主版本桶(**M107**;`None` = 全部版本)。 + /// + /// **纯视图态**(与 [`Self::SelectVersion`] 同一类):不派业务命令,app 层只改视图模型。 + SelectMajor(Option), + /// 折叠 / 展开第一级(版本类型)侧栏(**M107**)。纯视图态。 + ToggleTypeRail, /// 把现成文本塞进剪贴板(**M95**:诊断卡片的一行摘要,文本由页面造,app 只搬)。 CopySummary(String), /// 设置页的一组消息(**M101 `SETTINGSMSG`**):设置页的**唯一**投递口。 diff --git a/crates/app-iced/src/pages/screen.rs b/crates/app-iced/src/pages/screen.rs index 1026eeb..34b8e5d 100644 --- a/crates/app-iced/src/pages/screen.rs +++ b/crates/app-iced/src/pages/screen.rs @@ -12,7 +12,7 @@ use super::layout::PaneKit; pub enum Screen { /// 实例(list-detail)。 Instances, - /// 下载(单列)。 + /// **版本管理**(原下载页;单列页,但正文自己切三栏:类型 / 主版本 / 列表)。 Download, /// 设置(supporting-pane)。 Settings, diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index aee0d21..2406264 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -132,7 +132,7 @@ mod tests; use agent::agent_entries; use core_text::core_text_entries; -use download::download_entries; +use download::{download_entries, version_entries}; use font::font_entries; use general::general_entries; use instances::instances_entries; @@ -162,6 +162,11 @@ nav_entries! { logs_entries, agent_entries, general_entries, + // M107:版本管理页的侧栏 / 搜索文案**追加在链尾**(金表只追加不重排,理由见 + // `catalog/download.rs` 里 `version_entries` 的文档)。M104 的 `agent_entries` 与 M106 的 + // `general_entries` 已经在 main 上占了链尾,所以本域排在它们**之后**: + // `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,再 version。 + version_entries, finish, {} } diff --git a/crates/i18n/src/catalog/download.rs b/crates/i18n/src/catalog/download.rs index 14b9c18..b47d397 100644 --- a/crates/i18n/src/catalog/download.rs +++ b/crates/i18n/src/catalog/download.rs @@ -95,3 +95,36 @@ macro_rules! download_entries { } pub(crate) use download_entries; + +/// **M107 版本管理页**的侧栏 / 搜索文案。 +/// +/// 为什么单独一个宏、并**接在装配链最后**:`catalog/golden.rs` 是「`Key` 声明顺序 × 译文」的逐字节 +/// 快照,M107 的纪律是**只追加不重排**。把新 key 插在 `download_entries` 中间会让 `Key::ALL` +/// 的顺序在 `DownloadDismiss` 与 `LogsSubtitleIdle` 之间断开,金表就得整体重排(那正是要避免的 +/// 风险)。追加在链尾 ⇒ 新 key 落在 `Key::ALL` 末尾 ⇒ 金表末尾追加同样几行即可。 +macro_rules! version_entries { + ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { + $next! { $($rest,)* { + $($acc)* + // --- version management page (M107) --- + VersionRailTitle => ("Version type", "版本类型"), + VersionMajorTitle => ("Major version", "主版本"), + VersionMajorAll => ("All versions", "全部版本"), + VersionSearchPlaceholder => ("Search version numbers", "搜版本号"), + VersionSearchClear => ("Clear search", "清空搜索"), + VersionSearchHits => ("{count} matches", "{count} 个匹配"), + VersionSearchEmptyTitle => ("No matching versions", "没有匹配的版本"), + VersionSearchEmptyBody => ( + "Try a shorter piece of the number — 1.21, 24w, a1.", + "试试更短的片段 — 1.21、24w、a1。" + ), + VersionSearchElsewhere => ( + "{count} versions match, but they are in another type.", + "有 {count} 个版本匹配,但它们在别的类型里。" + ), + VersionSearchElsewhereAction => ("Show all types", "看全部类型"), + } } + }; +} + +pub(crate) use version_entries; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 486c956..6c2eea2 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -148,4 +148,15 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"), ("SettingsOverridesAdd", "Add override", "新增覆盖"), ("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"), + // --- M107 版本管理页(追加在链尾:`Key::ALL` 顺序 = 金表顺序) --- + ("VersionRailTitle", "Version type", "版本类型"), + ("VersionMajorTitle", "Major version", "主版本"), + ("VersionMajorAll", "All versions", "全部版本"), + ("VersionSearchPlaceholder", "Search version numbers", "搜版本号"), + ("VersionSearchClear", "Clear search", "清空搜索"), + ("VersionSearchHits", "{count} matches", "{count} 个匹配"), + ("VersionSearchEmptyTitle", "No matching versions", "没有匹配的版本"), + ("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"), + ("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"), + ("VersionSearchElsewhereAction", "Show all types", "看全部类型"), ];