From 57e8380b24c6e043c4f340903f8cf0d92255c550 Mon Sep 17 00:00:00 2001 From: tower Date: Mon, 2 Nov 2026 18:57:08 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(download):=20M94=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E9=A1=B5=E6=8D=A2=E6=88=90=E7=9C=9F=E7=BB=84=E4=BB=B6=20+=20?= =?UTF-8?q?=E7=9C=9F=E5=BF=AB=E7=85=A7=20+=20=E7=9C=9F=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=EF=BC=88PAGEDL=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面(crates/app-iced/src/pages/download*): - Tabs(分类)/ ListItem(版本行)/ filled 安装按钮 / progress / EmptyState / divider 全部取自 components/ 的真组件;页头动作换成 buttons::text - 数据只吃 pages::download::Download(app/view.rs 由 AppState 快照转,installed_only 与 download_query 照用);页面自己不留业务状态,交互只发意图 - 状态:加载(不确定进度 + 文案)/ 空(EmptyState + 刷新 CTA)/ 有数据 / 安装中 / 安装失败 (失败给可读文案 + 「打开日志」出口) 接线(追加式): - pages::Message 加 SelectVersion / InstallVersion / CancelInstall / SetCategory - app/update.rs 翻成 AppCommand::{InstallVersion, CancelInstall, ListVersions} (SelectVersion 只改视图模型;「刷新」= 当前分类重新拉,全部走 RefreshAll) - pages::Model 加 download 字段 文案:28 个 Download* key 进中英两套 catalog(一行一个 key 的全语言);页面零中文字面量, core 的中文阶段串(InstallStage::label / message)一律不进界面 i18n 结构:catalog.rs 的既有测试模块原样外置到 catalog/tests.rs(392 行 → 282 行, 逐字未改),新 key 才装得下 400 行硬上限 测试:app-iced 276 + i18n 23 全绿;clippy -D warnings 与 fmt --check 干净 --- crates/app-iced/src/app/update.rs | 25 +- .../src/app/update/download_wiring.rs | 65 +++ crates/app-iced/src/app/view.rs | 1 + crates/app-iced/src/pages/download.rs | 185 +++++---- crates/app-iced/src/pages/download/demo.rs | 132 ++++++ crates/app-iced/src/pages/download/install.rs | 185 +++++++++ crates/app-iced/src/pages/download/model.rs | 319 +++++++++++++++ crates/app-iced/src/pages/download/rows.rs | 121 ++++++ crates/app-iced/src/pages/download/states.rs | 72 ++++ crates/app-iced/src/pages/download/tests.rs | 384 ++++++++++++++++++ crates/app-iced/src/pages/mod.rs | 14 +- crates/app-iced/src/pages/model.rs | 5 +- crates/i18n/src/catalog.rs | 210 +++------- crates/i18n/src/catalog/tests.rs | 162 ++++++++ 14 files changed, 1633 insertions(+), 247 deletions(-) create mode 100644 crates/app-iced/src/app/update/download_wiring.rs create mode 100644 crates/app-iced/src/pages/download/demo.rs create mode 100644 crates/app-iced/src/pages/download/install.rs create mode 100644 crates/app-iced/src/pages/download/model.rs create mode 100644 crates/app-iced/src/pages/download/rows.rs create mode 100644 crates/app-iced/src/pages/download/states.rs create mode 100644 crates/app-iced/src/pages/download/tests.rs create mode 100644 crates/i18n/src/catalog/tests.rs diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 9cd355d..ab21dd4 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -284,10 +284,26 @@ impl App { pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), pages::Message::Appearance(change) => self.on_appearance(change), - // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) - pages::Message::Action(action) => { - eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); + // ── 下载页(M94):选中是**页面内**状态(不 dispatch),其余三条都派真指令。 + pages::Message::SelectVersion(id) => self.view.download.selected = Some(id), + pages::Message::InstallVersion(id) => self.dispatch(AppCommand::InstallVersion(id)), + pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall), + pages::Message::SetCategory(category) => { + // chip 立刻反映选中(视图模型),列表由后端回 `VersionListChanged` 换掉 + self.view.download.category = category; + if let Some(kind) = category.command() { + self.dispatch(AppCommand::ListVersions(kind)); + } } + // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) + pages::Message::Action(action) => match action { + // 「刷新下载源」不是占位动作:具体分类 → 重拉该分类;「全部」→ 让 host 重扫一遍 + pages::Action::RefreshSources => match self.view.download.category.command() { + Some(kind) => self.dispatch(AppCommand::ListVersions(kind)), + None => self.dispatch(AppCommand::RefreshAll), + }, + _ => eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"), + }, } } @@ -383,3 +399,6 @@ fn env_start_route() -> Option { _ => None, } } + +#[cfg(test)] +mod download_wiring; diff --git a/crates/app-iced/src/app/update/download_wiring.rs b/crates/app-iced/src/app/update/download_wiring.rs new file mode 100644 index 0000000..38cc095 --- /dev/null +++ b/crates/app-iced/src/app/update/download_wiring.rs @@ -0,0 +1,65 @@ +//! 下载页(M94)在 app 层的接线测试:**命令出得去、状态不自己变**。 +//! +//! 从 `app/update.rs` 外置(那边 348 行 + 这个模块会顶破 400 行硬上限;同一句话的理由见 +//! `crates/i18n/src/catalog/tests.rs`)。`use super::*` 依然指向 `crate::app::update`。 + +use std::cell::RefCell; +use std::rc::Rc; + +use unlucky_presenter::dto::{AppEvent, Backend, VersionCategory}; +use unlucky_presenter::settings_bridge::SettingsBridge; + +use super::*; + +/// 只记账、**不回事件**的后端(同 `app/tests/presenter.rs`):命令出了 UI 层,状态一个字节都不许变。 +/// 不用 `MockBackend`:它同步回事件,「本地乐观回显」与「事件回流」终态相同,断言会**假绿**。 +struct Recorder(Rc>>); + +impl Backend for Recorder { + fn drain_events(&mut self) -> Vec { + Vec::new() + } + fn dispatch(&mut self, command: AppCommand) { + self.0.borrow_mut().push(command); + } +} + +/// 四条下载页消息:三条派真指令,`SelectVersion` 只改视图模型(P3-2 的不乐观回显)。 +/// 抓得住:意图被写成「本地改状态就算完」、或派错指令;抓不住:host 收到后真的开始下载。 +#[test] +fn download_intents_dispatch_real_commands() { + let log: Rc>> = Rc::new(RefCell::new(Vec::new())); + let families = crate::theme::Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + let mut app = App::with_presenter( + crate::cli::Options::default(), + families, + SchemeFlavor::Light, + Box::new(Recorder(Rc::clone(&log))), + SettingsBridge::memory_only(), + ); + app.on_page(pages::Message::InstallVersion("1.21.9".to_string())); + app.on_page(pages::Message::CancelInstall); + app.on_page(pages::Message::SetCategory(pages::Category::Release)); + app.on_page(pages::Message::SelectVersion("1.21.8".to_string())); + let expected = vec![ + AppCommand::InstallVersion("1.21.9".to_string()), + AppCommand::CancelInstall, + AppCommand::ListVersions(VersionCategory::Release), + ]; + assert_eq!( + *log.borrow(), + expected, + "三条各派一条;`SelectVersion` 不派" + ); + assert_eq!(app.view.download.selected.as_deref(), Some("1.21.8")); + assert_eq!(app.view.download.category, pages::Category::Release); + assert_eq!( + app.view.download.rows.len(), + 0, + "recorder 不回事件 ⇒ 列表不该冒出来" + ); +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 80b4ece..bda3715 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -113,6 +113,7 @@ pub(super) fn model( window_width: width, active: route.nav(), instances: pages::Instances { rows, detail }, + download: pages::Download::from_snapshot(state, unlucky_i18n::Lang::En.catalog()), forced: forced.map(|state| match state { crate::cli::ForcedState::Hover => Interaction::Hovered, crate::cli::ForcedState::Pressed => Interaction::Pressed, diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index f8eea51..3fa82c9 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -4,103 +4,124 @@ //! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限), //! 多出来的宽度留白——这就是 M68「全宽单卡」的反面。 //! -//! 页头 1 个操作 + 空态 3 个快捷入口(12b §15.5:≤3)。 +//! ## 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`(拼错编译失败;中英两套目录) | +//! +//! ## 状态矩阵(四个状态都能从快照推出来,都出图) +//! +//! | 状态 | 条件 | 组件 | +//! |---|---|---| +//! | 加载 | 快照里没有版本列表([`Download::is_loading`]) | 不确定态进度条 + 文案([`states::loading`]) | +//! | 空 | 列表拉到了、过滤后为空([`Download::is_empty`]) | `EmptyState` + 刷新 CTA([`states::empty`]) | +//! | 有数据 | 有版本行 | Tabs + 计数头 + `ListItem` 列表([`rows`]) | +//! | 安装中 / 失败 | `AppState::install` 不为空 | 进度条 + 阶段 + 「取消」/「打开日志」([`install`]) | +//! +//! ## 意图 → 消息 → 指令(页面只发意图,不做乐观回显) +//! +//! | 用户动作 | 页面消息 | app 层落点 | +//! |---|---|---| +//! | 点分类 tab | [`Message::SetCategory`] | `AppCommand::ListVersions(category)`(`All` 不派:画现有快照) | +//! | 点一行 | [`Message::SelectVersion`] | **只改视图模型**(选中不是业务命令) | +//! | 点「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` | +//! | 点「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` | +//! | 点「刷新」/「打开日志」 | `Message::Action(RefreshSources)` / `Message::Navigate(Logs)` | `ListVersions` / `AppState::navigate` | +//! +//! 进度条只读 `model.install`:它由 presenter 的 `InstallProgress` 事件经 `apply` 灌进来。 +//! 于是「点了没变」只有一种解释——后端还没回事件,这正是要的(P3-2 的纪律)。 +//! +//! ## 版面判据(一条没动) +//! +//! 正文列 424 硬上限、断点 600 / 840、rail 88 都由 [`super::layout`] 与 `shell` 无条件施加, +//! 本页拿不到「更宽」的选项;页头仍按 M3 口径铺满页宽(偏离 #1,未变)。 -use iced::widget::{column, container, row}; -use iced::{Alignment, Element, Length}; +mod demo; +mod install; +mod model; +mod rows; +mod states; + +#[cfg(test)] +mod tests; + +pub use demo::demo; +pub use model::{Category, Download}; + +use iced::widget::column; +use iced::{Element, Length}; -use unlucky_design::color::Role; -use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; -use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key, Lang}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::divider; use crate::theme::Ui; use super::frame::{self, Header}; use super::layout::FrameMetrics; use super::{Action, Message}; -/// 整页。 -pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { - let header = Header::new("Download") - .subtitle("Versions, mod loaders and modpacks") - .action(frame::text_action( +/// 整页(**唯一**的真数据入口:`model` 由 `app/view.rs` 从 presenter 快照建)。 +/// +/// `lang` 是 i18n 的入参:`theme::Ui` 还没有 language 字段(那条在 M93 手里),产品路径现传 +/// [`Lang::En`](D28 的英文基线零中文负担);接上探测结果时只改调用点,页面不用动。 +pub fn view<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + model: &'a Download, + lang: Lang, +) -> Element<'a, Message> { + let catalog = lang.catalog(); + + let header = Header::new(catalog.text(Key::DownloadTitle)) + .subtitle(catalog.text(Key::DownloadSubtitle)) + .action(buttons::text( ui, - "Refresh sources", - Action::RefreshSources, + Button::new( + catalog.text(Key::DownloadRefresh), + Message::Action(Action::RefreshSources), + ), )); - let empty = frame::empty_state( - ui, - IconKind::Download, - "No downloads in progress", - "Pick a version or drop a modpack file here. Sources are probed per region and can be pinned \ - in Settings → Downloads.", - vec![ - frame::text_action(ui, "Browse versions", Action::BrowseVersions), - frame::text_action(ui, "Import modpack", Action::ImportModpack), - frame::text_action(ui, "Check sources", Action::CheckSources), - ], - ); - - let body = frame::single_column( - metrics, - column![source_status(ui), empty] - .spacing(ui.space(Space::X4)) - .width(Length::Fill) - .height(Length::Fill), - ); - - frame::page(ui, metrics, header, body) + frame::page(ui, metrics, header, body(ui, metrics, catalog, model)) } -/// 来源状态行(12pt,`OnSurfaceVariant`):说明这一页的数据从哪来,不是业务数据。 -fn source_status<'a>(ui: &'a Ui) -> Element<'a, Message> { - container( - row![ - frame::glyph( - IconKind::Check, - ui.color(Role::Secondary), - frame::ICON_SMALL_PX - ), - frame::text_role( - ui, - TypeRole::BodySmall, - false, - "3 sources healthy · BMCLAPI pinned by region · 2 min ago", - ui.color(Role::OnSurfaceVariant), - ), +/// 正文:加载 / 空 / 列表 + 底部安装面板(列宽由 [`super::layout`] 决议,页面只吃结果)。 +fn body<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + catalog: Catalog, + model: &'a Download, +) -> Element<'a, Message> { + let content = if model.is_loading() { + states::loading(ui, catalog) + } else if model.is_empty() { + states::empty(ui, catalog) + } else { + 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::X2)) - .align_y(Alignment::Center), - ) - .width(Length::Fill) - .into() -} + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .height(Length::Fill) + .into() + }; -#[cfg(test)] -mod tests { - use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit}; - - /// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。 - #[test] - fn download_is_single_column_at_every_width() { - for width in [600.0, 840.0, 900.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)); - } - } - - /// 宽窗下多出来的宽度必须留白(不是把卡片拉宽)——1180 的正文列 < 可用宽度。 - #[test] - fn extra_width_goes_to_margin_not_to_the_column() { - let metrics = frame_metrics(PaneKit::Single, 1180.0); - assert_eq!(metrics.column, measure_cap_px()); - assert!( - metrics.available - metrics.column >= 400.0, - "1180 宽下应留出大片留白(M68 的全宽卡就是没有这段留白)" - ); - } + frame::single_column(metrics, content) } diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs new file mode 100644 index 0000000..6fa5189 --- /dev/null +++ b/crates/app-iced/src/pages/download/demo.rs @@ -0,0 +1,132 @@ +//! 下载页的**演示数据**(截图 harness 与单测的输入,产品路径不读)。 +//! +//! 与 `pages/instances/data.rs` 同一条纪律:演示条目是**页面侧常量**,不是业务 DTO—— +//! 于是核心 DTO 每改一次字段,基线图不会跟着漂。真页面的数据只走 +//! [`Download::from_snapshot`](super::model::Download::from_snapshot)。 +//! +//! 演示条目本身也是一步「DTO → 视图模型」的真转换([`VersionRow::of`](super::model::VersionRow)、 +//! [`InstallView::of`](super::model::InstallView)):图上看到的副文案 / 进度行就是产品路径 +//! 那条转换的产物,不是另写一套拼串。 +//! +//! ## 为什么要一个环境变量 +//! +//! 状态矩阵(加载 / 空 / 有数据 / 安装中 / 安装失败)都要出图,而 harness 的 +//! `--screen` 只选页面、选不了页面状态: +//! +//! ```text +//! ULL_DL_DEMO=empty cargo run -p unlucky-app-iced --example pages_demo -- \ +//! --screen=download --screenshot=out.png --screenshot-theme=light --software-renderer +//! ``` +//! +//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `installing` / `failed`。 +//! **只有 harness 与演示走这里**:产品路径由 `app/view.rs` 从 presenter 快照建模型。 + +use unlucky_presenter::dto::{ + AncientKind, InstallProgressView, InstallStage, VersionKind, VersionSummary, +}; + +use unlucky_i18n::Catalog; + +use super::model::{Category, Download, InstallView, VersionRow}; + +/// 取证开关的名字([`demo`] 读它)。 +pub const DEMO_STATE_ENV: &str = "ULL_DL_DEMO"; + +/// 演示模型:按 [`DEMO_STATE_ENV`] 选状态,缺省是「有数据」。 +pub fn demo(catalog: Catalog) -> Download { + match std::env::var(DEMO_STATE_ENV).ok().as_deref() { + Some("loading") => Download::loading(), + Some("empty") => Download { + loaded: true, + ..Download::loading() + }, + Some("installing") => Download { + install: Some(install(InstallStage::Downloading, 0.42, catalog)), + selected: Some("1.21.9".to_string()), + ..ready(catalog) + }, + Some("selected") => Download { + selected: Some("1.21.9".to_string()), + ..ready(catalog) + }, + Some("failed") => Download { + install: Some(install(InstallStage::Failed, 0.0, catalog)), + selected: Some("1.21.9".to_string()), + ..ready(catalog) + }, + _ => ready(catalog), + } +} + +/// 有数据:六行(三条正式、一条预览、两条远古),与 presenter 的 `mock_versions` 同形。 +/// +/// 不复用 `mock_data::mock_versions()`:演示数据是**出图的基线**,得比 mock 稳定 +/// (mock 随 P3 的假数据一起漂)。 +fn ready(catalog: Catalog) -> Download { + fn summary(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary { + VersionSummary { + id: id.to_string(), + kind, + released: released.to_string(), + installed, + } + } + + let 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.21.9-pre3", VersionKind::Snapshot, "2026-09-01", false), + summary( + "rd-132211", + VersionKind::Ancient(AncientKind::Classic), + "2009-05-13", + false, + ), + summary( + "a1.2.6", + VersionKind::Ancient(AncientKind::OldAlpha), + "2010-08-11", + false, + ), + ]; + + let rows: Vec = versions + .iter() + .map(|version| VersionRow::of(version, catalog)) + .collect(); + + Download { + // 混合分类 → 没有单个 chip 该亮(这正是 `Category::of` 的反推结果) + category: Category::of(&versions), + rows, + selected: None, + install: None, + loaded: true, + } +} + +/// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。 +fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView { + let total = 512; + let done = (f64::from(ratio) * total as f64) as u64; + let failed = matches!(stage, InstallStage::Failed); + + InstallView::of( + &InstallProgressView { + version: "1.21.9".to_string(), + instance: "1.21.9".to_string(), + // 演示也**不**搬 core 的中文串:`loader` 字段这里给的是结构化徽标的英文名 + loader: "Vanilla".to_string(), + stage, + files_done: if failed { 137 } else { done }, + files_total: total, + bytes_done: 0, + bytes_total: 0, + speed_bps: if failed { 0 } else { 1_258_291 }, + eta_secs: if failed { 0 } else { 75 }, + message: String::new(), + }, + catalog, + ) +} diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs new file mode 100644 index 0000000..5adb216 --- /dev/null +++ b/crates/app-iced/src/pages/download/install.rs @@ -0,0 +1,185 @@ +//! 安装面板(页面底部):选中版本 → 「安装」/ 进度 → 「取消」/ 失败 → 「打开日志」。 +//! +//! ## 这一块为什么钉在底部而不是跟着列表滚 +//! +//! 列表是**线性进度项 + 版本集**,装的是「几十行」;底部按钮如果跟着滚,用户在列表中间永远 +//! 看不到「安装」在哪。所以列表独立滚动(`rows::list`),面板固定占尾部一行高度。 +//! +//! ## 只有 intent,没有乐观回显 +//! +//! | 控件 | 消息 | app 层落点 | +//! |---|---|---| +//! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` | +//! | 「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` | +//! | 「打开日志」 | [`Message::Navigate(Route::Logs)]` | `AppState::navigate`(不派指令) | +//! +//! 按钮按下**不改任何本地状态**:进度条读的是 `model.install`,它只由 presenter 的 +//! `InstallProgress` 事件灌进来(`apply` → 视图模型重建)。于是「点了没变」= 后端还没回事件, +//! 正是要的行为。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_presenter::router::Route; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::feedback::{linear, Progress}; +use crate::theme::Ui; + +use super::super::frame; +use super::super::Message; +use super::model::{Download, InstallView, VersionRow}; + +/// 不确定态进度条的相位(页面没有帧订阅 ⇒ 不做动画,取一帧好看的静态相位)。 +const INDETERMINATE_PHASE: f32 = 0.35; + +/// 底部的安装面板。 +pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> { + let content = match &model.install { + Some(install) => installing(ui, catalog, install), + None => match model.selected_row() { + Some(entry) => ready(ui, catalog, entry), + None => hint(ui, catalog), + }, + }; + + container(content) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X3), 0.0])) + .into() +} + +/// 没选中任何版本:给一句「选一个版本再安装」(不静默禁用,也不画一个点不动的按钮)。 +fn hint<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::DownloadSelectHint), + ui.color(Role::OnSurfaceVariant), + ) +} + +/// 选好了:版本 id + 「安装」(全屏唯一实心 accent,rubric K2)。 +/// +/// 已装的版本给**禁用**的 Outlined 按钮(`Button::disabled` 只清消息、保留几何): +/// 「已装」是列表行上的事实(overline + 勾),按钮再点一次不会更清楚。 +fn ready<'a>(ui: &'a Ui, catalog: Catalog, entry: &'a VersionRow) -> Element<'a, Message> { + let label = container(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + entry.id.as_str(), + ui.color(Role::OnSurface), + )) + .width(Length::Fill); + + let action = if entry.installed { + buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstall))) + } else { + buttons::filled( + ui, + Button::new( + catalog.text(Key::DownloadInstall), + Message::InstallVersion(entry.id.clone()), + ) + .leading(IconKind::Download), + ) + }; + + row![label.width(Length::Fill), action] + .width(Length::Fill) + .align_y(Alignment::Center) + .into() +} + +/// 安装中 / 已收尾:阶段 + 进度条 + 计数 + 速率,失败时给可读文案与出口。 +fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> { + let bar = if install.known { + linear(ui, Progress::determinate(install.ratio)) + } else { + linear(ui, Progress::indeterminate(INDETERMINATE_PHASE)) + }; + + let mut body = column![ + row![ + container(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + install.version.as_str(), + ui.color(Role::OnSurface), + )) + .width(Length::Fill), + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + install.stage_label.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center), + bar, + frame::text_role( + ui, + TypeRole::BodySmall, + false, + install.detail.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill); + + if let Some(rate) = &install.rate { + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + rate.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + + // 失败态:**可读的原因出口**(结构化文案 + 去日志页),不静默、不搬 core 的中文串。 + if install.failed() { + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::DownloadFailedHint), + ui.color(Role::Error), + )); + } + + let action = if install.running() { + buttons::text( + ui, + Button::new(catalog.text(Key::DownloadCancel), Message::CancelInstall), + ) + } else if install.failed() { + buttons::outlined( + ui, + Button::new( + catalog.text(Key::DownloadOpenLogs), + Message::Navigate(Route::Logs), + ), + ) + } else { + frame::gap(0.0) + }; + + column![body, action] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .into() +} diff --git a/crates/app-iced/src/pages/download/model.rs b/crates/app-iced/src/pages/download/model.rs new file mode 100644 index 0000000..ce7b5fe --- /dev/null +++ b/crates/app-iced/src/pages/download/model.rs @@ -0,0 +1,319 @@ +//! 下载页视图模型:**presenter 快照 → 页面要画的东西**(与 [`super::super::model`] 同一套分层)。 +//! +//! ## 为什么这一页也要中间层 +//! +//! 页面层不认识 presenter 的 DTO(同实例页的理由):真页面吃 `AppState` 的快照, +//! 演示数据与单测吃 [`super::demo`],两边都转成这里的纯展示字段。 +//! +//! 文案在这一层就按 [`Catalog`] 求好值(`supporting` / `detail` / `rate` 都是 `String`): +//! iced 的文字控件**借用**内容,行内文案又是 `format!` 出来的,不先落成字段就没法进控件树 +//! (`pages/frame` 的 `text_role_owned` 是同一个理由的单行版)。 +//! +//! ## 快照里**没有**的东西(都登记在 `pages/mod.rs` 的偏离表里) +//! +//! | 缺什么 | 后果 | 兜法 | +//! |---|---|---| +//! | 「版本列表已加载完成」标志 | 空列表既可能是「还没拉到」也可能是「真的没有」 | [`Download::loaded`] 按「快照里有没有版本」判:没有 = 加载中 | +//! | 版本列表**失败**事件 | 拉取失败会停在加载态(不静默:页面上有说明与刷新入口) | 错误态借安装阶段的 `Failed`(结构化、可翻译) | +//! | 「当前选中」字段 | 选中态存在模型里,任何快照重建都会回到「正在安装的那个版本」 | 见 `pages/mod.rs` 偏离登记;长期要靠 presenter 加字段 | +//! +//! ## 中文串一个都不许画 +//! +//! `InstallProgressView::message` / `InstallStage::label()` 都是**中文业务串**(`i18n::bridge` +//! 的规矩 2:core 的中文串只给日志和 CLI)。所以这里只搬**结构化字段**(阶段枚举、计数、字节), +//! 文案按 `Key` 现求——中文环境自然出中文,英文环境不需要任何中文字体。 + +use unlucky_presenter::dto::{ + human_bytes, AncientKind, InstallProgressView, InstallStage, VersionCategory, VersionKind, + VersionSummary, +}; +use unlucky_presenter::state::AppState; + +use unlucky_i18n::{Catalog, Key}; + +/// 分类筛选(页面上的四个 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 + } + } +} + +/// 版本列表的一行(`ListItem` 的两行档:headline = 版本 id,supporting = `正式版 · 发布于 …`)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionRow { + /// 版本 id(`1.21.9` / `rd-132211`)——也是「安装」指令的载荷 + pub id: String, + /// 发布日期(ISO-8601 日期部分,快照原样) + pub released: String, + /// 版本类型 + pub kind: VersionKind, + /// 本地是否已安装(行上给勾 / overline `Installed`) + pub installed: bool, + /// 副文案(`正式版 · 发布于 2026-09-20`;一行 = 一个 key,不拼片段) + pub supporting: String, +} + +impl VersionRow { + pub(super) fn of(version: &VersionSummary, catalog: Catalog) -> Self { + Self { + id: version.id.clone(), + released: version.released.clone(), + kind: version.kind, + installed: version.installed, + supporting: catalog.render( + Key::DownloadRowSupporting, + &[ + ("kind", catalog.text(kind_key(version.kind))), + ("date", version.released.as_str()), + ], + ), + } + } +} + +/// 版本类型 → 文案 key(远古版不分子类:chip 只有一档,行上再分子类等于两套语言)。 +const fn kind_key(kind: VersionKind) -> Key { + match kind { + VersionKind::Release => Key::DownloadCategoryRelease, + VersionKind::Snapshot => Key::DownloadCategorySnapshot, + VersionKind::Ancient(_) => Key::DownloadCategoryAncient, + } +} + +/// 一次安装的展示态(结构化字段 + 已求好的文案)。 +#[derive(Debug, Clone, PartialEq)] +pub struct InstallView { + /// 目标版本(= 选中的那一行) + pub version: String, + /// 阶段 + pub stage: InstallStage, + /// 阶段词(`下载文件`) + pub stage_label: String, + /// 计数行(`128/512 文件`;未知时是「等第一个文件…」) + pub detail: String, + /// 速率 + 剩余时间(`1.2 MB/s · 剩 1m15s`;未知时 `None`) + pub rate: Option, + /// 整体进度 0.0~1.0 + pub ratio: f32, + /// 进度是否已知(`false` = 进度条画不确定态) + pub known: bool, +} + +impl InstallView { + /// 快照 → 展示态。 + pub(super) fn of(progress: &InstallProgressView, catalog: Catalog) -> Self { + let known = progress.is_known(); + let detail = if known { + catalog.render( + Key::DownloadProgress, + &[ + ("done", &progress.files_done.to_string()), + ("total", &progress.files_total.to_string()), + ], + ) + } else { + catalog.text(Key::DownloadProgressUnknown).to_owned() + }; + let rate = if progress.speed_bps == 0 || progress.eta_secs == 0 { + None + } else { + let eta = if progress.eta_secs >= 60 { + catalog.render( + Key::DownloadEtaMins, + &[ + ("mins", &(progress.eta_secs / 60).to_string()), + ("secs", &format!("{:02}", progress.eta_secs % 60)), + ], + ) + } else { + catalog.render( + Key::DownloadEtaSecs, + &[("secs", &progress.eta_secs.to_string())], + ) + }; + Some(catalog.render( + Key::DownloadRate, + &[ + ("speed", &format!("{}/s", human_bytes(progress.speed_bps))), + ("eta", eta.as_str()), + ], + )) + }; + + Self { + version: progress.version.clone(), + stage: progress.stage, + stage_label: catalog.text(stage_key(progress.stage)).to_owned(), + detail, + rate, + ratio: progress.ratio(), + known, + } + } + + /// 失败了(错误态要出可读文案 + 出口,不许静默)。 + pub const fn failed(&self) -> bool { + matches!(self.stage, InstallStage::Failed) + } + + /// 收尾了(`Done` / `Failed` / `Cancelled`)。 + pub fn terminal(&self) -> bool { + self.stage.is_terminal() + } + + /// 还在跑(「取消安装」只在这时给)。 + pub fn running(&self) -> bool { + !self.stage.is_terminal() + } +} + +/// 阶段 → 文案 key(**不**用 `InstallStage::label()`:那是中文业务串)。 +const fn stage_key(stage: InstallStage) -> Key { + match stage { + InstallStage::Planning => Key::DownloadStagePlanning, + InstallStage::Downloading => Key::DownloadStageDownloading, + InstallStage::Installing => Key::DownloadStageInstalling, + InstallStage::Registering => Key::DownloadStageRegistering, + InstallStage::Done => Key::DownloadStageDone, + InstallStage::Failed => Key::DownloadStageFailed, + InstallStage::Cancelled => Key::DownloadStageCancelled, + } +} + +/// 下载页要画的东西(**全部字段都能从快照推出来**,页面自己不留状态)。 +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Download { + /// 版本列表(顺序 = 快照顺序) + pub rows: Vec, + /// 当前分类(从快照反推) + pub category: Category, + /// 选中的版本 id(`None` = 没选中 → 安装按钮禁用) + pub selected: Option, + /// 正在跑(或刚跑完)的安装 + pub install: Option, + /// 快照里有没有版本列表(`false` = 还没拉到 → 加载态) + pub loaded: bool, +} + +impl Download { + /// 空模型 = 加载态(首帧,以及 `pages::Model::new` 的默认值)。 + pub fn loading() -> Self { + Self::default() + } + + /// presenter 快照 → 下载页模型(**唯一的真数据入口**)。 + /// + /// 快照里的两个过滤条件(`installed_only` / `download_query`)原样照用:它们是 presenter 的 + /// 状态,页面不该另存一份;过滤后为空就是「空态」(`loaded` 仍为 `true`)。 + pub fn from_snapshot(state: &AppState, catalog: Catalog) -> Self { + let query = state.download_query.trim().to_ascii_lowercase(); + let rows = state + .versions + .iter() + .filter(|version| !state.installed_only || version.installed) + .filter(|version| query.is_empty() || version.id.to_ascii_lowercase().contains(&query)) + .map(|version| VersionRow::of(version, catalog)) + .collect::>(); + + let install = state + .install + .as_ref() + .map(|view| InstallView::of(view, catalog)); + // 快照没有「选中」字段:正在安装的那个版本就是「选中」(见模块文档的缺口表)。 + let selected = install.as_ref().map(|view| view.version.clone()); + + Self { + rows, + category: Category::of(&state.versions), + selected, + install, + loaded: !state.versions.is_empty(), + } + } + + /// 选中的那一行(安装面板上的版本名)。 + pub fn selected_row(&self) -> Option<&VersionRow> { + let id = self.selected.as_deref()?; + self.rows.iter().find(|row| row.id == id) + } + + /// 加载态:快照里还没有版本列表。 + pub const fn is_loading(&self) -> bool { + !self.loaded + } + + /// 空态:列表拉到了,但过滤之后一行都不剩。 + pub fn is_empty(&self) -> bool { + self.loaded && self.rows.is_empty() + } +} diff --git a/crates/app-iced/src/pages/download/rows.rs b/crates/app-iced/src/pages/download/rows.rs new file mode 100644 index 0000000..a5c632e --- /dev/null +++ b/crates/app-iced/src/pages/download/rows.rs @@ -0,0 +1,121 @@ +//! 版本列表:分类 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`) | +//! +//! 行高从「手搓 56」变成 60 的理由与实例页完全一样:真 `ListItem` 自己的几何真源,页面不覆盖。 + +use iced::widget::{column, scrollable}; +use iced::{Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::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::Message; +use super::model::{Category, Download, VersionRow}; + +/// 行与行的间距(`Space::X1`;与实例页同值,行高差异全部来自 `ListItem` 自己的几何)。 +const ROW_GAP: Space = Space::X1; + +/// 分类筛选(Primary Tabs:选中项下方 3px `Primary` 指示条)。 +/// +/// 点一个分类 = `Message::SetCategory`,由 app 层翻成 `AppCommand::ListVersions`—— +/// 页面**不**自己过滤列表(列表内容 = 快照里有什么),所以「点了但没反应」只会发生在 +/// presenter 没回列表的时候,不会出现「页面过滤过、后端还是旧分类」的两份真相。 +pub(super) fn categories<'a>( + ui: &'a Ui, + catalog: Catalog, + current: Category, +) -> 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); + } + tabs(ui, row) +} + +/// 计数头(`LabelMedium`):一行 = 一个 key,数字走占位符,不拼片段。 +pub(super) fn head<'a>(ui: &'a Ui, catalog: Catalog, model: &Download) -> Element<'a, Message> { + frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + catalog.render( + Key::DownloadCount, + &[("count", &model.rows.len().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 + .rows + .iter() + .map(|entry| version_row(ui, catalog, model, entry)) + .collect::>(), + ) + .spacing(ui.space(ROW_GAP)) + .padding(Padding::from([0.0, ui.space(Space::X2)])) + .width(Length::Fill); + + scrollable(rows) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new() + .width(frame::SCROLLBAR_WIDTH) + .scroller_width(frame::SCROLLBAR_WIDTH) + .margin(frame::SCROLLBAR_MARGIN), + )) + .height(Length::Fill) + .style(frame::scroll_style(ui)) + .into() +} + +/// 一行:headline = 版本 id,supporting = `正式版 · 发布于 2026-09-20`,已装给勾。 +/// +/// 点一行 = `Message::SelectVersion(id)`(**页面内选中**,不 dispatch);「安装」是旁边那个 +/// 独立按钮,点它才派 `AppCommand::InstallVersion`。选中不做乐观回显:行的高亮来自模型, +/// 模型在 app 层被改写之后才变。 +fn version_row<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &Download, + entry: &'a VersionRow, +) -> Element<'a, Message> { + let mut item = ListItem::new(entry.id.as_str(), Message::SelectVersion(entry.id.clone())) + .supporting(entry.supporting.as_str()) + .leading(IconKind::Download); + + if entry.installed { + item = item + .overline(catalog.text(Key::DownloadInstalled)) + .trailing(IconKind::Check); + } + if model.selected.as_deref() == Some(entry.id.as_str()) { + item = item.selected(); + } + + list_item(ui, item) +} diff --git a/crates/app-iced/src/pages/download/states.rs b/crates/app-iced/src/pages/download/states.rs new file mode 100644 index 0000000..c9577ae --- /dev/null +++ b/crates/app-iced/src/pages/download/states.rs @@ -0,0 +1,72 @@ +//! 加载态与空态(**没有可用版本数据时页面不能是白屏**)。 +//! +//! | 状态 | 触发条件(都能从快照推出来) | 画什么 | +//! |---|---|---| +//! | 加载 | `model.is_loading()`:快照里还没有版本列表 | 不确定态进度条 + 「正在加载版本列表…」+ 一句说明 | +//! | 空 | `model.is_empty()`:列表拉到了,但过滤后一行不剩 | 真 [`empty_state`](图标块 96 + 24 标题 + 说明 + 一个刷新 CTA) | +//! +//! 两者都**不留白**:加载态给得出「还在等」的视觉证据(进度条不是装饰,是唯一的非文字信号), +//! 空态给得出「下一步做什么」(刷新)。 +//! +//! 拉取**失败**在这条通道上没有结构化事件(presenter 只有 `VersionListChanged`),所以列表拉不到 +//! 会停在加载态而不是谎报「没有版本」——加载态的说明文案里就写着这件事。安装失败的错误态在 +//! [`super::install`] 里(那里的 `Failed` 阶段是结构化字段,可以放心翻译)。 + +use iced::widget::column; +use iced::{Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::feedback::{empty_state, linear, EmptyState, Progress}; +use crate::theme::Ui; + +use super::super::frame; +use super::super::{Action, Message}; + +/// 不确定态进度条的相位(没有帧订阅,取一帧静态相位;见 `install.rs` 同名常量)。 +const INDETERMINATE_PHASE: f32 = 0.35; + +/// 加载态。 +pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { + column![ + frame::text_role( + ui, + TypeRole::BodyLarge, + true, + catalog.text(Key::DownloadLoading), + ui.color(Role::OnSurface), + ), + linear(ui, Progress::indeterminate(INDETERMINATE_PHASE)), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::DownloadLoadingHint), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .into() +} + +/// 空态(真 `EmptyState`:CTA 就是标准 Filled 按钮,走按钮家族)。 +pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { + empty_state( + ui, + EmptyState::new( + IconKind::Download, + catalog.text(Key::DownloadEmptyTitle), + catalog.text(Key::DownloadEmptyBody), + ) + .action( + catalog.text(Key::DownloadRefresh), + Message::Action(Action::RefreshSources), + ), + ) +} diff --git a/crates/app-iced/src/pages/download/tests.rs b/crates/app-iced/src/pages/download/tests.rs new file mode 100644 index 0000000..2bdb637 --- /dev/null +++ b/crates/app-iced/src/pages/download/tests.rs @@ -0,0 +1,384 @@ +//! 下载页的判据:**快照 → 模型 → 控件树**三段各自的断言。 +//! +//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯转换与树能构建,**像素**由报告里的 +//! 四张图与数值表负责(同 `pages/tests.rs` 的口径)。 + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; +use unlucky_i18n::{Key, Lang}; +use unlucky_presenter::dto::{ + AncientKind, AppEvent, CoreEvent, InstallProgressView, InstallStage, VersionCategory, + VersionKind, VersionSummary, +}; +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::model::{Category, Download}; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui() -> Ui { + Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) +} + +fn version(id: &str, kind: VersionKind, installed: bool) -> VersionSummary { + VersionSummary { + id: id.to_string(), + kind, + released: "2026-09-20".to_string(), + installed, + } +} + +fn state_with(versions: Vec) -> AppState { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions))); + state +} + +// --------------------------------------------------------------- 版面(M84 的三条,M94 一条没动) + +/// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。 +/// +/// 抓得住:`layout` 把下载页切到两列、或把正文列放过 424。 +/// 抓不住:页面内部又套了一层更宽的容器(那要看真像素,见报告的量测表)。 +#[test] +fn download_is_single_column_at_every_width() { + for width in [600.0, 840.0, 900.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)); + } +} + +/// 宽窗下多出来的宽度必须留白(不是把卡片拉宽)——1180 的正文列 < 可用宽度。 +#[test] +fn extra_width_goes_to_margin_not_to_the_column() { + let metrics = frame_metrics(PaneKit::Single, 1180.0); + assert_eq!(metrics.column, measure_cap_px()); + assert!( + metrics.available - metrics.column >= 400.0, + "1180 宽下应留出大片留白(M68 的全宽卡就是没有这段留白)" + ); +} + +// --------------------------------------------------------------- 快照 → 模型 + +/// 真快照进得来:行数、分类、选中项、已装标记全部来自 `AppState`。 +/// +/// 抓得住:页面自己造数据、或把 `installed` / `kind` 丢了。 +/// 抓不住:快照字段在 `app/view.rs` 被漏接(那由 `app/update.rs` 的接线测试兜)。 +#[test] +fn the_snapshot_becomes_the_page_model() { + let mut state = state_with(vec![ + version("1.21.9", VersionKind::Release, false), + version("1.21.8", VersionKind::Release, true), + ]); + state.apply(AppEvent::Core(CoreEvent::InstallProgress(Some( + InstallProgressView { + version: "1.21.9".to_string(), + loader: "Vanilla".to_string(), + stage: InstallStage::Downloading, + files_done: 128, + files_total: 512, + speed_bps: 1_258_291, + eta_secs: 75, + ..InstallProgressView::new() + }, + )))); + + let model = Download::from_snapshot(&state, Lang::En.catalog()); + + 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::Release, + "整份列表都是正式版 → 反推出正式版分类" + ); + assert_eq!( + model.selected.as_deref(), + Some("1.21.9"), + "正在安装的版本 = 选中行" + ); + assert!(model.loaded, "有版本 = 已加载"); + + let install = model.install.expect("安装进度要转进来"); + assert!(install.running(), "Downloading 还没收尾"); + assert!(!install.failed()); + 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 的过滤条件丢了(用户设了「仅已装」,页面照画全部)。 +/// 抓不住:过滤词大小写之外的语言学规则(那属于 presenter 的搜索口径)。 +#[test] +fn presenter_filters_still_apply() { + let mut state = state_with(vec![ + version("1.21.9", VersionKind::Release, false), + version("1.21.8", VersionKind::Release, true), + version("1.20.4", VersionKind::Release, true), + ]); + + let installed_only = { + state.installed_only = true; + Download::from_snapshot(&state, Lang::En.catalog()) + }; + assert_eq!(installed_only.rows.len(), 2, "仅已装"); + + let queried = { + state.installed_only = false; + state.download_query = " 1.21 ".to_string(); + Download::from_snapshot(&state, Lang::En.catalog()) + }; + assert_eq!(queried.rows.len(), 2, "版本号片段匹配两个"); + assert!(queried.rows.iter().all(|row| row.id.starts_with("1.21"))); +} + +/// 空快照 = 加载态;过滤掉所有行 = 空态。两者都**不许**是白屏。 +/// +/// 抓得住:把「还没拉到」画成「没有版本」(谎报空态),或反过来。 +/// 抓不住:真机上卡在加载态多久(没有超时信号,见模块文档的缺口表)。 +#[test] +fn loading_and_empty_are_different_states() { + let loading = Download::from_snapshot(&AppState::new(), Lang::En.catalog()); + assert!(loading.is_loading() && !loading.is_empty()); + + let mut state = state_with(vec![version("1.21.9", VersionKind::Release, false)]); + state.download_query = "no-such-version".to_string(); + let empty = Download::from_snapshot(&state, Lang::En.catalog()); + assert!(!empty.is_loading(), "列表拉到了,不是加载中"); + assert!(empty.is_empty(), "过滤后没有行 = 空态"); +} + +/// 分类反推:混合 = 全部,远古 = 远古(不看子类)。 +#[test] +fn the_category_is_derived_from_the_list() { + let mixed = vec![ + version("1.21.9", VersionKind::Release, false), + version("1.21.9-pre3", VersionKind::Snapshot, false), + ]; + assert_eq!(Category::of(&mixed), Category::All); + assert_eq!( + Category::of(&[version("x", VersionKind::Snapshot, false)]), + Category::Snapshot + ); + assert_eq!( + Category::of(&[version( + "rd-132211", + VersionKind::Ancient(AncientKind::OldBeta), + false + )]), + Category::Ancient + ); + assert_eq!(Category::of(&[]), Category::All); +} + +/// 分类 chip 派的是**真指令**:`VersionCategory` 直接喂给 `AppCommand::ListVersions`。 +/// +/// 抓得住:chip 点到不存在的指令、或把 `All` 也派出去(那会白拉一次列表)。 +/// 抓不住:后端是否真按分类回列表(presenter/host 的测试负责)。 +#[test] +fn category_commands_are_real() { + assert_eq!(Category::All.command(), None, "全部 = 画现有快照,不派指令"); + assert_eq!(Category::Release.command(), Some(VersionCategory::Release)); + assert_eq!( + Category::Snapshot.command(), + Some(VersionCategory::Snapshot) + ); + assert_eq!( + Category::Ancient.command(), + Some(VersionCategory::Ancient(AncientKind::Classic)), + "远古版先拉 classic(四子类同屏要 presenter 支持,已登记 finding)" + ); + assert!(Category::Ancient.matches(VersionKind::Ancient(AncientKind::Unlisted))); + assert!(!Category::Release.matches(VersionKind::Snapshot)); +} + +// --------------------------------------------------------------- 文案(i18n) + +/// 中英两套目录都能把这一页的每个 key 求出来(缺译会露出 `⟪missing⟫`)。 +/// +/// 「这一页的 key」按 `Download` 前缀取,不手写清单:清单会漏、会在下一次加 key 时不同步, +/// 而前缀遍历顺手还守住了「不许把 key 悄悄删掉」(条数下限)。 +/// +/// 抓得住:新增 key 只写了一种语言(另一套会显示 `⟪missing⟫`)。 +/// 抓不住:译文质量,以及**没有用到的** key(那是 `catalog` 自己的完整性测试)。 +#[test] +fn every_download_key_exists_in_both_catalogs() { + let download_keys: Vec = Key::ALL + .iter() + .copied() + .filter(|key| key.name().starts_with("Download")) + .collect(); + assert!(download_keys.len() >= 25, "下载页的 key 不该被悄悄删掉"); + + for lang in [Lang::En, Lang::ZhCn] { + let catalog = lang.catalog(); + for key in &download_keys { + let text = catalog.text(*key); + assert_ne!(text, unlucky_i18n::catalog::MISSING, "{key:?} 缺译"); + assert!(!text.is_empty(), "{key:?} 是空串"); + } + assert!(catalog.missing().is_empty(), "内置目录不许有缺译"); + } +} + +/// 页面级的文案全部按 `Catalog` 走:中文目录出中文,英文目录出英文,**且不含 core 的中文业务串**。 +/// +/// 抓得住:页面里又出现硬编码英文/中文字面量、或把 `InstallStage::label()` / `progress.message` +/// (core 的中文串)画到界面上。 +/// 抓不住:目录里那条译文本身写错了。 +#[test] +fn the_model_speaks_the_catalog_language() { + let versions = vec![version("1.21.9", VersionKind::Release, false)]; + let state = state_with(versions); + + let en = Download::from_snapshot(&state, Lang::En.catalog()); + let zh = Download::from_snapshot(&state, Lang::ZhCn.catalog()); + + assert_eq!(en.rows[0].supporting, "Release · released 2026-09-20"); + assert_eq!(zh.rows[0].supporting, "正式版 · 发布于 2026-09-20"); + + let progress = InstallProgressView { + version: "1.21.9".to_string(), + stage: InstallStage::Planning, + message: "正在解析版本清单…".to_string(), + ..InstallProgressView::new() + }; + let install_en = super::model::InstallView::of(&progress, Lang::En.catalog()); + assert_eq!(install_en.stage_label, "Planning"); + assert_eq!(install_en.detail, "Waiting for the first file…"); + assert!( + !install_en.stage_label.contains('解'), + "core 的中文阶段串不许直接上屏" + ); + assert!( + !install_en.detail.contains('正'), + "`InstallProgressView::message` 是中文业务串,页面不许画它" + ); + + let install_zh = super::model::InstallView::of(&progress, Lang::ZhCn.catalog()); + assert_eq!(install_zh.stage_label, "解析版本"); +} + +// --------------------------------------------------------------- 控件树 + +/// 五个状态都能构建出控件树(分支走到、不 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 { + 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('版')), + "英文目录下不许出现中文类型词" + ); +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 95e5e4f..f916f5f 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -106,6 +106,7 @@ mod settings; #[cfg(test)] mod tests; +pub use download::{Category, Download}; pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; pub use model::{Detail, Instances, Meta, Model, Row}; @@ -141,6 +142,14 @@ pub enum Message { /// 载荷是**用户请求值**(未钳位):钳位与 CPU 档降级在 `appearance::alpha` 里做, /// 页面只表达意图(与组件库「不持有业务状态」同一条纪律)。 Appearance(appearance::Change), + /// 选中下载页的一个版本(M94):**页面内选中**,app 层只改视图模型,不 dispatch。 + SelectVersion(String), + /// 安装指定版本(M94;app 层翻成 `AppCommand::InstallVersion`)。 + InstallVersion(String), + /// 取消正在跑的安装(M94;`AppCommand::CancelInstall`)。 + CancelInstall, + /// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。 + SetCategory(Category), } /// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。 @@ -316,7 +325,7 @@ fn render<'a>( match model.screen { Screen::Instances => instances::view(ui, metrics, model), - Screen::Download => download::view(ui, metrics), + Screen::Download => download::view(ui, metrics, &model.download, unlucky_i18n::Lang::En), Screen::Settings => settings::view(ui, metrics, appearance), Screen::Logs => logs::view(ui, metrics), } @@ -353,6 +362,9 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model { if screen == Screen::Instances { model.instances = instances::demo(); } + if screen == Screen::Download { + model.download = download::demo(unlucky_i18n::Lang::En.catalog()); + } model } diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index f0261e8..a0f7c7a 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -18,7 +18,7 @@ use unlucky_presenter::router::NavSection; use crate::app::components::interaction::Interaction; -use super::Screen; +use super::{Download, Screen}; /// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -83,6 +83,8 @@ pub struct Model { pub active: Option, /// 实例页的数据(其它三页还是占位视觉,不读) pub instances: Instances, + /// 下载页的数据(M94:版本列表 / 选中 / 安装进度,全部来自 presenter 快照) + pub download: Download, /// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。 /// /// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的; @@ -98,6 +100,7 @@ impl Model { window_width, active, instances: Instances::default(), + download: Download::loading(), forced: None, } } diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index 6fc8b7f..d2d6318 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -115,6 +115,55 @@ catalog! { // ── core / presenter 只给了中文串、UI 又不许画原文时的兜底(见 `bridge`) CoreTextUntranslated => ("[system text not translated]", "[未翻译的系统文案]"), + + // --- download page (M94) --- + // 下载页(`crates/app-iced/src/pages/download`)。一句话一个 key:带变量的句子用 `{name}`, + // 页面只做 `Catalog::render`,不拼片段(`bridge` 的规矩 1)。 + DownloadTitle => ("Downloads", "下载"), + DownloadSubtitle => ("Version list from the official manifest", "来自官方版本清单的版本列表"), + DownloadRefresh => ("Refresh", "刷新"), + DownloadInstall => ("Install", "安装"), + DownloadCancel => ("Cancel install", "取消安装"), + DownloadInstalled => ("Installed", "已安装"), + DownloadCount => ("{count} versions", "{count} 个版本"), + DownloadRowSupporting => ("{kind} · released {date}", "{kind} · 发布于 {date}"), + DownloadSelectHint => ("Pick a version, then install", "选一个版本再安装"), + DownloadSelected => ("Selected {version}", "已选 {version}"), + DownloadLoading => ("Loading the version list…", "正在加载版本列表…"), + DownloadLoadingHint => ( + "The list appears after the first successful fetch; this page never goes blank.", + "首次拉取成功后这里会出现版本列表;这一页不会是一片白。" + ), + DownloadEmptyTitle => ("No matching versions", "没有匹配的版本"), + DownloadEmptyBody => ( + "Switch the category or clear the filter to see more.", + "换一个分类或清掉筛选后再看。" + ), + DownloadCategoryAll => ("All", "全部"), + DownloadCategoryRelease => ("Release", "正式版"), + DownloadCategorySnapshot => ("Snapshot", "预览版"), + DownloadCategoryAncient => ("Ancient", "远古版"), + DownloadStagePlanning => ("Planning", "解析版本"), + DownloadStageDownloading => ("Downloading files", "下载文件"), + DownloadStageInstalling => ("Building the version", "生成版本"), + DownloadStageRegistering => ("Registering the instance", "登记实例"), + DownloadStageDone => ("Done", "已完成"), + DownloadStageFailed => ("Install failed", "安装失败"), + DownloadStageCancelled => ("Install cancelled", "安装已取消"), + DownloadProgress => ( + "{done}/{total} files", + "{done}/{total} 文件" + ), + DownloadProgressUnknown => ("Waiting for the first file…", "等第一个文件…"), + // 速率与剩余时间是两个独立短语,各占一个 key(`DownloadRate` 只负责把它们排成一行) + DownloadRate => ("{speed} · {eta}", "{speed},{eta}"), + DownloadEtaSecs => ("{secs}s left", "剩 {secs}s"), + DownloadEtaMins => ("{mins}m{secs}s left", "剩 {mins}m{secs}s"), + DownloadFailedHint => ( + "The Logs page has the full reason.", + "完整原因在日志页。" + ), + DownloadOpenLogs => ("Open logs", "打开日志"), } /// 一套语言的目录(不可变;`Copy`,随手传值)。 @@ -229,164 +278,5 @@ pub fn placeholders(text: &str) -> Vec<&str> { } found } - #[cfg(test)] -mod tests { - use super::*; - - /// 手写一套**故意缺条**的目录(不经过 `catalog!`,模拟后续 mission 的注入表)。 - const PARTIAL_ZH: &[(Key, &str)] = &[ - (Key::NavInstances, "实例"), - (Key::NavLogs, "日志"), - (Key::NavSettings, "设置"), - (Key::NavDownload, "下载"), - (Key::InstancesNew, "新建实例"), - (Key::InstancesImport, "导入"), - (Key::InstancesBack, "返回实例列表"), - (Key::InstancesPlay, "启动"), - (Key::InstancesLastPlayed, "上次游玩 {when}"), - (Key::InstancesEmptyTitle, "未选择实例"), - (Key::InstancesEmptyBody, "选择一个实例查看详情。"), - (Key::LoaderVanilla, "原版"), - (Key::LaunchOutcomeNormal, "正常结束"), - (Key::LaunchOutcomeCrash, "游戏崩溃"), - // 缺:LaunchOutcomeSilent / LaunchFailedWithCode / FontCjk* / Settings* / CoreTextUntranslated - ]; - - #[test] - fn built_in_catalogs_have_every_key() { - // 抓得住:`catalog!` 的成对生成被绕开(有人改回「两套各写一遍」并漏了一条 / 抄重了一条)。 - // 判据是**集合相等**:`Key` 枚举拦住了「表里有、枚举没有」,这里拦反方向。 - // 抓不住:译文对不对(这只能是人工 / 后续的语言审查)。 - for lang in Lang::ALL { - let catalog = Catalog::of(*lang); - assert_eq!(catalog.missing(), Vec::new(), "{}", catalog.lang().tag()); - assert_eq!(catalog.diagnose(), None); - - let mut keys = catalog - .entries() - .iter() - .map(|(key, _)| *key) - .collect::>(); - let total = keys.len(); - keys.sort_unstable(); - keys.dedup(); - assert_eq!(keys.len(), total, "{} 目录里有重复条目", lang.tag()); - assert_eq!( - keys.len(), - Key::ALL.len(), - "{} 条目数与 key 数不等", - lang.tag() - ); - - for key in Key::ALL { - assert!( - catalog.get(*key).is_some(), - "{} 缺 {}", - lang.tag(), - key.name() - ); - } - } - assert!(!Key::ALL.is_empty()); - } - - #[test] - fn key_names_are_unique_and_stable() { - // 抓得住:同一个 key 被写成两个变体(名字重复会让日志/诊断指认失准)。 - // 抓不住:重命名变体对下游源码的影响(那是编译错误,本来就跑不过)。 - let mut names = Key::ALL.iter().map(|key| key.name()).collect::>(); - let total = names.len(); - names.sort_unstable(); - names.dedup(); - assert_eq!(names.len(), total); - assert_eq!(Key::NavInstances.name(), "NavInstances"); - } - - #[test] - fn chinese_catalog_is_actually_chinese() { - // 抓得住:整句漏译(英文被抄进中文表);一条都没有说明这张表是真翻过的。 - // 抓不住:语义翻错、语气不合(机器能查的只有「与英文不同」这一层)。 - let en = Catalog::of(Lang::En); - let zh = Catalog::of(Lang::ZhCn); - for key in Key::ALL { - let english = en.text(*key); - assert_ne!(english, zh.text(*key), "{} 未翻译", key.name()); - assert!(!english.is_empty() && !zh.text(*key).is_empty()); - } - } - - #[test] - fn placeholder_sets_match_across_languages() { - // 抓得住:一处文案的 `{code}` / `{when}` 在另一套译文里丢了或写错名—— - // 那会让 render() 输出半截句子,或让参数静默失效。 - // 抓不住:占位符本身是不是正确的变量(那是调用方的职责,由页面侧测试守)。 - let en = Catalog::of(Lang::En); - let zh = Catalog::of(Lang::ZhCn); - for key in Key::ALL { - let mut left = placeholders(en.text(*key)); - let mut right = placeholders(zh.text(*key)); - left.sort_unstable(); - right.sort_unstable(); - assert_eq!(left, right, "{}", key.name()); - } - assert_eq!(placeholders("exit {code} at {when}"), vec!["code", "when"]); - assert_eq!(placeholders("没有占位符"), Vec::<&str>::new()); - } - - #[test] - fn missing_rows_are_diagnosed_in_plain_words() { - // 抓得住:缺译诊断的**可读性**(必须点名语言、key 名与英文原文)——注入目录缺条时靠它定位。 - // 抓不住:内置目录真的缺译(内置表由 catalog! 结构保证,见上一条测试)。 - let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH); - assert_eq!(partial.missing().len(), Key::ALL.len() - PARTIAL_ZH.len()); - - let report = partial.diagnose().expect("应当报缺译"); - assert!(report.starts_with("zh-CN 缺 "), "{report}"); - assert!(report.contains("LaunchOutcomeSilent"), "{report}"); - assert!( - report.contains("Suspected silent failure"), - "诊断要带上英文原文当参考:{report}" - ); - assert!( - !report.contains("NavInstances"), - "已译的 key 不该出现在报告里:{report}" - ); - } - - #[test] - fn missing_translation_never_falls_back_silently() { - // 抓得住:缺译被静默顶替(回落英文 / 回落中文 / 返回空串)——那会产出「半句中文半句英文」的界面。 - // 抓不住:MISSING 标记真的被画到屏幕上(那是 UI 侧截图验收的事)。 - let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH); - assert_eq!(partial.get(Key::LaunchOutcomeSilent), None); - assert_eq!(partial.text(Key::LaunchOutcomeSilent), MISSING); - assert_eq!( - partial.render( - Key::LaunchFailedWithCode, - &[("code", "0"), ("reason", "JVM 拒绝启动")] - ), - "⟪missing:LaunchFailedWithCode⟫" - ); - } - - #[test] - fn render_substitutes_and_keeps_unfilled_placeholders_visible() { - // 抓得住:占位符替换的回归(漏参时**保留** `{code}` 而不是清空、不 panic)。 - // 抓不住:调用方传没传对值(`("code", "0")` 是不是真的退出码)。 - let zh = Catalog::of(Lang::ZhCn); - assert_eq!( - zh.render( - Key::LaunchFailedWithCode, - &[("code", "0"), ("reason", "mod 冲突")] - ), - "启动失败(退出码 0):mod 冲突" - ); - assert_eq!( - zh.render(Key::LaunchFailedWithCode, &[("code", "0")]), - "启动失败(退出码 0):{reason}" - ); - assert_eq!(zh.text(Key::InstancesNew), "新建实例"); - assert_eq!(zh.lang(), Lang::ZhCn); - } -} +mod tests; diff --git a/crates/i18n/src/catalog/tests.rs b/crates/i18n/src/catalog/tests.rs new file mode 100644 index 0000000..e42488a --- /dev/null +++ b/crates/i18n/src/catalog/tests.rs @@ -0,0 +1,162 @@ +//! `Catalog` 的守门测试(M94 从 `catalog.rs` 原样搬出来:那边已经贴着 400 行硬上限, +//! 而页面每加一批 key 都要在这个模块里占一行)。 +//! +//! **零改动搬运**:用例、注释、断言一字未动;`use super::*` 指向的仍是 `crate::catalog`。 + +use super::*; + +/// 手写一套**故意缺条**的目录(不经过 `catalog!`,模拟后续 mission 的注入表)。 +const PARTIAL_ZH: &[(Key, &str)] = &[ + (Key::NavInstances, "实例"), + (Key::NavLogs, "日志"), + (Key::NavSettings, "设置"), + (Key::NavDownload, "下载"), + (Key::InstancesNew, "新建实例"), + (Key::InstancesImport, "导入"), + (Key::InstancesBack, "返回实例列表"), + (Key::InstancesPlay, "启动"), + (Key::InstancesLastPlayed, "上次游玩 {when}"), + (Key::InstancesEmptyTitle, "未选择实例"), + (Key::InstancesEmptyBody, "选择一个实例查看详情。"), + (Key::LoaderVanilla, "原版"), + (Key::LaunchOutcomeNormal, "正常结束"), + (Key::LaunchOutcomeCrash, "游戏崩溃"), + // 缺:LaunchOutcomeSilent / LaunchFailedWithCode / FontCjk* / Settings* / CoreTextUntranslated +]; + +#[test] +fn built_in_catalogs_have_every_key() { + // 抓得住:`catalog!` 的成对生成被绕开(有人改回「两套各写一遍」并漏了一条 / 抄重了一条)。 + // 判据是**集合相等**:`Key` 枚举拦住了「表里有、枚举没有」,这里拦反方向。 + // 抓不住:译文对不对(这只能是人工 / 后续的语言审查)。 + for lang in Lang::ALL { + let catalog = Catalog::of(*lang); + assert_eq!(catalog.missing(), Vec::new(), "{}", catalog.lang().tag()); + assert_eq!(catalog.diagnose(), None); + + let mut keys = catalog + .entries() + .iter() + .map(|(key, _)| *key) + .collect::>(); + let total = keys.len(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), total, "{} 目录里有重复条目", lang.tag()); + assert_eq!( + keys.len(), + Key::ALL.len(), + "{} 条目数与 key 数不等", + lang.tag() + ); + + for key in Key::ALL { + assert!( + catalog.get(*key).is_some(), + "{} 缺 {}", + lang.tag(), + key.name() + ); + } + } + assert!(!Key::ALL.is_empty()); +} + +#[test] +fn key_names_are_unique_and_stable() { + // 抓得住:同一个 key 被写成两个变体(名字重复会让日志/诊断指认失准)。 + // 抓不住:重命名变体对下游源码的影响(那是编译错误,本来就跑不过)。 + let mut names = Key::ALL.iter().map(|key| key.name()).collect::>(); + let total = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), total); + assert_eq!(Key::NavInstances.name(), "NavInstances"); +} + +#[test] +fn chinese_catalog_is_actually_chinese() { + // 抓得住:整句漏译(英文被抄进中文表);一条都没有说明这张表是真翻过的。 + // 抓不住:语义翻错、语气不合(机器能查的只有「与英文不同」这一层)。 + let en = Catalog::of(Lang::En); + let zh = Catalog::of(Lang::ZhCn); + for key in Key::ALL { + let english = en.text(*key); + assert_ne!(english, zh.text(*key), "{} 未翻译", key.name()); + assert!(!english.is_empty() && !zh.text(*key).is_empty()); + } +} + +#[test] +fn placeholder_sets_match_across_languages() { + // 抓得住:一处文案的 `{code}` / `{when}` 在另一套译文里丢了或写错名—— + // 那会让 render() 输出半截句子,或让参数静默失效。 + // 抓不住:占位符本身是不是正确的变量(那是调用方的职责,由页面侧测试守)。 + let en = Catalog::of(Lang::En); + let zh = Catalog::of(Lang::ZhCn); + for key in Key::ALL { + let mut left = placeholders(en.text(*key)); + let mut right = placeholders(zh.text(*key)); + left.sort_unstable(); + right.sort_unstable(); + assert_eq!(left, right, "{}", key.name()); + } + assert_eq!(placeholders("exit {code} at {when}"), vec!["code", "when"]); + assert_eq!(placeholders("没有占位符"), Vec::<&str>::new()); +} + +#[test] +fn missing_rows_are_diagnosed_in_plain_words() { + // 抓得住:缺译诊断的**可读性**(必须点名语言、key 名与英文原文)——注入目录缺条时靠它定位。 + // 抓不住:内置目录真的缺译(内置表由 catalog! 结构保证,见上一条测试)。 + let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH); + assert_eq!(partial.missing().len(), Key::ALL.len() - PARTIAL_ZH.len()); + + let report = partial.diagnose().expect("应当报缺译"); + assert!(report.starts_with("zh-CN 缺 "), "{report}"); + assert!(report.contains("LaunchOutcomeSilent"), "{report}"); + assert!( + report.contains("Suspected silent failure"), + "诊断要带上英文原文当参考:{report}" + ); + assert!( + !report.contains("NavInstances"), + "已译的 key 不该出现在报告里:{report}" + ); +} + +#[test] +fn missing_translation_never_falls_back_silently() { + // 抓得住:缺译被静默顶替(回落英文 / 回落中文 / 返回空串)——那会产出「半句中文半句英文」的界面。 + // 抓不住:MISSING 标记真的被画到屏幕上(那是 UI 侧截图验收的事)。 + let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH); + assert_eq!(partial.get(Key::LaunchOutcomeSilent), None); + assert_eq!(partial.text(Key::LaunchOutcomeSilent), MISSING); + assert_eq!( + partial.render( + Key::LaunchFailedWithCode, + &[("code", "0"), ("reason", "JVM 拒绝启动")] + ), + "⟪missing:LaunchFailedWithCode⟫" + ); +} + +#[test] +fn render_substitutes_and_keeps_unfilled_placeholders_visible() { + // 抓得住:占位符替换的回归(漏参时**保留** `{code}` 而不是清空、不 panic)。 + // 抓不住:调用方传没传对值(`("code", "0")` 是不是真的退出码)。 + let zh = Catalog::of(Lang::ZhCn); + assert_eq!( + zh.render( + Key::LaunchFailedWithCode, + &[("code", "0"), ("reason", "mod 冲突")] + ), + "启动失败(退出码 0):mod 冲突" + ); + assert_eq!( + zh.render(Key::LaunchFailedWithCode, &[("code", "0")]), + "启动失败(退出码 0):{reason}" + ); + assert_eq!(zh.text(Key::InstancesNew), "新建实例"); + assert_eq!(zh.lang(), Lang::ZhCn); +} From 9d6ee70301db9311f82aa14e5b59ac6f03069a58 Mon Sep 17 00:00:00 2001 From: tower Date: Mon, 2 Nov 2026 20:02:50 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(download):=20M94=20r1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=BA=94=E6=9D=A1=E5=A4=8D=E5=AE=A1=EF=BC=88P2-1/2?= =?UTF-8?q?/3/4/6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1 占位规则统一:加载态原先贴顶、空态居中 → 两态都走同一套「内容在可用区居中」 (依据:components::feedback::empty_state 的既有口径 12b §15.4;两态互切不再纵向跳)。 实测两个状态的内容中心线 y=449 / y=445(差 4px)。 P2-2 文案只说真能做到的事: - 删「The list appears after the first successful fetch; this page never goes blank.」 (工程话术 + 承诺了 UI 做不到的「永空白」)→「如果一直停在这里,点右上角的刷新。」 (对应页头那个真的 Refresh 按钮) - 空态「Switch the category or clear the filter」删掉「清筛选」(页面没有筛选控件) →「换一个分类,或刷新一次列表。」 - 副标题「Version list from the official manifest」去掉 manifest 这种工程词 →「可以安装的版本」 P2-3 去掉静止的 indeterminate 进度条:页面没有帧订阅,indeterminate 只会拿到一个固定相位 (看起来像 17% 卡住)。加载态改成纯文字;安装面板只在进度已知时画 determinate。 两帧证据:215/512 → 条填 177px(42%),409/512 → 338px(80%),条随事件走而不是动画。 P2-4 失败态与安装中彻底不同构:失败不再画进度条,改成整块 ErrorContainer 面板 (ErrorContainer 底 + Error 图标 + Error 色标题 + 停在哪个文件 + 说明 + 实心「打开日志」)。 实测底色:浅色 (255,218,214) / 深色 (147,0,10),安装中面板底色仍是页面底色。 P2-6 死 key 与假守卫: - 删掉没用的 DownloadSelected; - 用真的守卫替换原来那条「前缀遍历 + 条数下限」:every_download_key_is_referenced_by_the_page 用 include_str! 把页面源码编进来,目录里每个 Download* key 必须在源码里出现 Key::Xxx。 实证:把 DownloadSelected 放回去 → 该测试红并点名 key;删掉 → 绿。 注释里也写清抓不住的三种情况(反向是编译错误 / 译文完整性归 i18n 自己的测试 / 宏拼 key 能骗过它)。 门禁:fmt --check 干净、clippy -D warnings 干净、app-iced 276 + i18n 23 全绿; tests.rs 384→383 行(不再贴顶),其余文件均 ≤400。 --- crates/app-iced/src/pages/download.rs | 10 +- crates/app-iced/src/pages/download/demo.rs | 8 +- crates/app-iced/src/pages/download/install.rs | 182 ++++++++++++------ crates/app-iced/src/pages/download/states.rs | 93 +++++---- crates/app-iced/src/pages/download/tests.rs | 63 +++--- crates/i18n/src/catalog.rs | 18 +- 6 files changed, 229 insertions(+), 145 deletions(-) diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index 3fa82c9..9db5fbe 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -22,10 +22,14 @@ //! //! | 状态 | 条件 | 组件 | //! |---|---|---| -//! | 加载 | 快照里没有版本列表([`Download::is_loading`]) | 不确定态进度条 + 文案([`states::loading`]) | -//! | 空 | 列表拉到了、过滤后为空([`Download::is_empty`]) | `EmptyState` + 刷新 CTA([`states::empty`]) | +//! | 加载 | 快照里没有版本列表([`Download::is_loading`]) | 标题 + 说明,**居中、无进度形状**([`states::loading`]) | +//! | 空 | 列表拉到了、过滤后为空([`Download::is_empty`]) | `EmptyState` + 刷新 CTA,**同样居中**([`states::empty`]) | //! | 有数据 | 有版本行 | Tabs + 计数头 + `ListItem` 列表([`rows`]) | -//! | 安装中 / 失败 | `AppState::install` 不为空 | 进度条 + 阶段 + 「取消」/「打开日志」([`install`]) | +//! | 安装中 | `AppState::install` 在跑 | 阶段 + **进度条(仅当进度已知)** + 计数 + 「取消」([`install`]) | +//! | 失败 | `install.stage == Failed` | **`ErrorContainer` 面板**:`Error` 图标/标题 + 实心「打开日志」,**没有进度条**([`install`]) | +//! +//! 两个占位状态(加载 / 空)走**同一套规则**:内容在可用区居中(依据:`feedback::empty_state` +//! 的既有口径 `12b §15.4`,且两态互切时内容不会纵向跳)。 //! //! ## 意图 → 消息 → 指令(页面只发意图,不做乐观回显) //! diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index 6fa5189..fc20016 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -18,7 +18,7 @@ //! --screen=download --screenshot=out.png --screenshot-theme=light --software-renderer //! ``` //! -//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `installing` / `failed`。 +//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `installing` / `installing-late` / `failed`。 //! **只有 harness 与演示走这里**:产品路径由 `app/view.rs` 从 presenter 快照建模型。 use unlucky_presenter::dto::{ @@ -45,6 +45,12 @@ pub fn demo(catalog: Catalog) -> Download { selected: Some("1.21.9".to_string()), ..ready(catalog) }, + // 同一状态的第二个采样点:进度条随**事件**推进(不是动画),出图时两帧对照 + Some("installing-late") => Download { + install: Some(install(InstallStage::Downloading, 0.8, catalog)), + selected: Some("1.21.9".to_string()), + ..ready(catalog) + }, Some("selected") => Download { selected: Some("1.21.9".to_string()), ..ready(catalog) diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index 5adb216..834dcbf 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -2,26 +2,37 @@ //! //! ## 这一块为什么钉在底部而不是跟着列表滚 //! -//! 列表是**线性进度项 + 版本集**,装的是「几十行」;底部按钮如果跟着滚,用户在列表中间永远 -//! 看不到「安装」在哪。所以列表独立滚动(`rows::list`),面板固定占尾部一行高度。 +//! 列表装的是几十行版本;底部按钮如果跟着滚,用户在列表中间永远看不到「安装」在哪。 +//! 所以列表独立滚动(`rows::list`),面板固定占尾部一块高度。 //! -//! ## 只有 intent,没有乐观回显 +//! ## 只有意图,没有乐观回显 //! //! | 控件 | 消息 | app 层落点 | //! |---|---|---| //! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` | //! | 「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` | -//! | 「打开日志」 | [`Message::Navigate(Route::Logs)]` | `AppState::navigate`(不派指令) | +//! | 「打开日志」 | [`Message::Navigate(Route::Logs)`] | `AppState::navigate`(不派指令) | //! //! 按钮按下**不改任何本地状态**:进度条读的是 `model.install`,它只由 presenter 的 -//! `InstallProgress` 事件灌进来(`apply` → 视图模型重建)。于是「点了没变」= 后端还没回事件, -//! 正是要的行为。 +//! `InstallProgress` 事件灌进来(`apply` → 视图模型重建)。于是「点了没变」= 后端还没回事件。 +//! +//! ## 三种面板形态刻意不同构(M94 r1 / P2-3 + P2-4) +//! +//! | 状态 | 形态 | 为什么 | +//! |---|---|---| +//! | 选好了 | 版本 id + 实心「安装」 | 本页唯一的实心按钮(rubric K2) | +//! | 安装中 | 版本 id + 阶段 + **进度条**(仅当进度已知)+ 计数 + 文本「取消安装」 | 有进度才有条 | +//! | 失败 | **整块 `ErrorContainer` 面板**:`Error` 图标 + `Error` 色标题 + 停在哪 + 实心「打开日志」 | **不画进度条**——进度条正是「看起来还在跑」的来源;颜色/图标/措辞/按钮四样一起变,才一眼可辨 | +//! +//! 进度未知(刚派发、后端还没给文件计数)时**不画条**:页面没有帧订阅,`Progress::indeterminate` +//! 只会得到一个静止的假进度(复审 P2-3 的「17% 卡住」),不如只写「等第一个文件…」。 use iced::widget::{column, container, row}; -use iced::{Alignment, Element, Length, Padding}; +use iced::{Alignment, Background, Border, Element, Length, Padding}; use unlucky_design::color::Role; use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; @@ -37,9 +48,6 @@ use super::super::frame; use super::super::Message; use super::model::{Download, InstallView, VersionRow}; -/// 不确定态进度条的相位(页面没有帧订阅 ⇒ 不做动画,取一帧好看的静态相位)。 -const INDETERMINATE_PHASE: f32 = 0.35; - /// 底部的安装面板。 pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> { let content = match &model.install { @@ -94,52 +102,50 @@ fn ready<'a>(ui: &'a Ui, catalog: Catalog, entry: &'a VersionRow) -> Element<'a, ) }; - row![label.width(Length::Fill), action] + row![label, action] .width(Length::Fill) .align_y(Alignment::Center) .into() } -/// 安装中 / 已收尾:阶段 + 进度条 + 计数 + 速率,失败时给可读文案与出口。 +/// 安装中 / 已收尾(失败另走 [`failed`])。 fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> { - let bar = if install.known { - linear(ui, Progress::determinate(install.ratio)) - } else { - linear(ui, Progress::indeterminate(INDETERMINATE_PHASE)) - }; + if install.failed() { + return failed(ui, catalog, install); + } - let mut body = column![ - row![ - container(frame::text_role( - ui, - TypeRole::BodyMedium, - false, - install.version.as_str(), - ui.color(Role::OnSurface), - )) - .width(Length::Fill), - frame::text_role( - ui, - TypeRole::LabelMedium, - false, - install.stage_label.as_str(), - ui.color(Role::OnSurfaceVariant), - ), - ] - .width(Length::Fill) - .align_y(Alignment::Center), - bar, + let head = row![ + container(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + install.version.as_str(), + ui.color(Role::OnSurface), + )) + .width(Length::Fill), frame::text_role( ui, - TypeRole::BodySmall, + TypeRole::LabelMedium, false, - install.detail.as_str(), + install.stage_label.as_str(), ui.color(Role::OnSurfaceVariant), ), ] - .spacing(ui.space(Space::X2)) - .width(Length::Fill); + .width(Length::Fill) + .align_y(Alignment::Center); + let mut body = column![head]; + // 进度已知才画条:未知时画 indeterminate 会得到一个静止的假进度(见模块文档)。 + if install.known { + body = body.push(linear(ui, Progress::determinate(install.ratio))); + } + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + install.detail.as_str(), + ui.color(Role::OnSurfaceVariant), + )); if let Some(rate) = &install.rate { body = body.push(frame::text_role( ui, @@ -150,36 +156,84 @@ fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Ele )); } - // 失败态:**可读的原因出口**(结构化文案 + 去日志页),不静默、不搬 core 的中文串。 - if install.failed() { - body = body.push(frame::text_role( - ui, - TypeRole::BodySmall, - false, - catalog.text(Key::DownloadFailedHint), - ui.color(Role::Error), - )); - } - let action = if install.running() { buttons::text( ui, Button::new(catalog.text(Key::DownloadCancel), Message::CancelInstall), ) - } else if install.failed() { - buttons::outlined( - ui, - Button::new( - catalog.text(Key::DownloadOpenLogs), - Message::Navigate(Route::Logs), - ), - ) } else { frame::gap(0.0) }; - column![body, action] + column![ + body.spacing(ui.space(Space::X2)).width(Length::Fill), + action + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .into() +} + +/// 失败态:一整块 `ErrorContainer` 面板(**没有进度条**)。 +/// +/// 四样一起变,保证与「正在下载」一眼可辨:容器色(`ErrorContainer`)/ 图标(`Error`)/ +/// 措辞(`Install failed` 用 `Error` 色标题)/ 主按钮(实心「打开日志」,不是文本按钮)。 +fn failed<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> { + let head = row![ + frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::DownloadStageFailed), + ui.color(Role::Error), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + + let stopped = frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + format!("{} · {}", install.version, install.detail), + ui.color(Role::OnErrorContainer), + ); + + let action = buttons::filled( + ui, + Button::new( + catalog.text(Key::DownloadOpenLogs), + Message::Navigate(Route::Logs), + ) + .leading(IconKind::Logs), + ); + + container( + column![ + head, + stopped, + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::DownloadFailedHint), + ui.color(Role::OnErrorContainer), + ), + action, + ] .spacing(ui.space(Space::X2)) - .width(Length::Fill) - .into() + .width(Length::Fill), + ) + .width(Length::Fill) + .padding(ui.space(Space::X4)) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::ErrorContainer))), + border: Border { + radius: ui.radius_dp(ShapeScale::Large), + ..Border::default() + }, + ..container::Style::default() + }) + .into() } diff --git a/crates/app-iced/src/pages/download/states.rs b/crates/app-iced/src/pages/download/states.rs index c9577ae..d890787 100644 --- a/crates/app-iced/src/pages/download/states.rs +++ b/crates/app-iced/src/pages/download/states.rs @@ -1,19 +1,30 @@ -//! 加载态与空态(**没有可用版本数据时页面不能是白屏**)。 +//! 占位状态(加载 / 空):**同一套占位规则——内容在可用区居中**(M94 r1 / P2-1)。 //! -//! | 状态 | 触发条件(都能从快照推出来) | 画什么 | +//! | 状态 | 条件(都能从快照推出来) | 画什么 | //! |---|---|---| -//! | 加载 | `model.is_loading()`:快照里还没有版本列表 | 不确定态进度条 + 「正在加载版本列表…」+ 一句说明 | -//! | 空 | `model.is_empty()`:列表拉到了,但过滤后一行不剩 | 真 [`empty_state`](图标块 96 + 24 标题 + 说明 + 一个刷新 CTA) | +//! | 加载 | `model.is_loading()`:快照里还没有版本列表 | 标题 + 一句说明(**没有任何进度形状**) | +//! | 空 | `model.is_empty()`:列表拉到了,但过滤后一行不剩 | 真 [`empty_state`](图标块 96 + 24 标题 + 说明 + 刷新 CTA) | //! -//! 两者都**不留白**:加载态给得出「还在等」的视觉证据(进度条不是装饰,是唯一的非文字信号), -//! 空态给得出「下一步做什么」(刷新)。 +//! ## 为什么是「都居中」(P2-1) //! -//! 拉取**失败**在这条通道上没有结构化事件(presenter 只有 `VersionListChanged`),所以列表拉不到 -//! 会停在加载态而不是谎报「没有版本」——加载态的说明文案里就写着这件事。安装失败的错误态在 -//! [`super::install`] 里(那里的 `Failed` 阶段是结构化字段,可以放心翻译)。 +//! `components::feedback::empty_state` 的既有口径(`12b §15.4`)就是「内容在可用区居中」; +//! 加载态原先贴顶,等于同一个页面里两套占位规则,两态互切时内容会纵向跳一次。所以本轮把加载态 +//! 也放进同一条规则里。**有数据时列表仍然贴顶**——那是有内容的状态,不属于占位规则。 +//! +//! ## 为什么加载态没有进度条(P2-3) +//! +//! 页面**没有帧订阅**(`app/subscription.rs` 的 `needs_frames` 归 app 层),所以 +//! `Progress::indeterminate(phase)` 拿到的是一个**静止的**相位——用户看到的是「17% 卡住」。 +//! 与其留一个「看起来有进度但不动」的控件,不如给一句明确的「正在加载」文字;等 app 层真有了 +//! 帧订阅再来带动画(那时这里换回 indeterminate 才有意义)。 +//! +//! ## 文案口径(P2-2) +//! +//! 只说页面真能做到的动作:说明里那句「点右上角的刷新」对应页头那个真的 Refresh 按钮 +//! (`Message::Action(Action::RefreshSources)`);没有写「稍后会自动重试」这种 UI 做不到的承诺。 -use iced::widget::column; -use iced::{Element, Length}; +use iced::widget::{column, container}; +use iced::{Alignment, Element, Length}; use unlucky_design::color::Role; use unlucky_design::icons::IconKind; @@ -22,42 +33,50 @@ use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key}; -use crate::app::components::feedback::{empty_state, linear, EmptyState, Progress}; +use crate::app::components::feedback::{empty_state, EmptyState}; use crate::theme::Ui; use super::super::frame; use super::super::{Action, Message}; -/// 不确定态进度条的相位(没有帧订阅,取一帧静态相位;见 `install.rs` 同名常量)。 -const INDETERMINATE_PHASE: f32 = 0.35; +/// 两个占位状态共用的外壳:水平 + 垂直都居中(见模块文档的两条理由)。 +fn centered<'a>(content: impl Into>) -> Element<'a, Message> { + container(content) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into() +} -/// 加载态。 +/// 加载态:一句「正在加载」+ 一句这个页面真能做的事(点刷新)。 pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { - column![ - frame::text_role( - ui, - TypeRole::BodyLarge, - true, - catalog.text(Key::DownloadLoading), - ui.color(Role::OnSurface), - ), - linear(ui, Progress::indeterminate(INDETERMINATE_PHASE)), - frame::text_role( - ui, - TypeRole::BodySmall, - false, - catalog.text(Key::DownloadLoadingHint), - ui.color(Role::OnSurfaceVariant), - ), - ] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .into() + centered( + column![ + frame::text_role_centered( + ui, + TypeRole::TitleMedium, + true, + catalog.text(Key::DownloadLoading), + ui.color(Role::OnSurface), + ), + frame::text_role_centered( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::DownloadLoadingHint), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_x(Alignment::Center), + ) } /// 空态(真 `EmptyState`:CTA 就是标准 Filled 按钮,走按钮家族)。 pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { - empty_state( + centered(empty_state( ui, EmptyState::new( IconKind::Download, @@ -68,5 +87,5 @@ pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { catalog.text(Key::DownloadRefresh), Message::Action(Action::RefreshSources), ), - ) + )) } diff --git a/crates/app-iced/src/pages/download/tests.rs b/crates/app-iced/src/pages/download/tests.rs index 2bdb637..2dd580d 100644 --- a/crates/app-iced/src/pages/download/tests.rs +++ b/crates/app-iced/src/pages/download/tests.rs @@ -73,8 +73,6 @@ fn extra_width_goes_to_margin_not_to_the_column() { // --------------------------------------------------------------- 快照 → 模型 /// 真快照进得来:行数、分类、选中项、已装标记全部来自 `AppState`。 -/// -/// 抓得住:页面自己造数据、或把 `installed` / `kind` 丢了。 /// 抓不住:快照字段在 `app/view.rs` 被漏接(那由 `app/update.rs` 的接线测试兜)。 #[test] fn the_snapshot_becomes_the_page_model() { @@ -125,8 +123,6 @@ fn the_snapshot_becomes_the_page_model() { } /// 快照里的两个过滤条件(`installed_only` / `download_query`)原样照用。 -/// -/// 抓得住:页面把 presenter 的过滤条件丢了(用户设了「仅已装」,页面照画全部)。 /// 抓不住:过滤词大小写之外的语言学规则(那属于 presenter 的搜索口径)。 #[test] fn presenter_filters_still_apply() { @@ -151,9 +147,7 @@ fn presenter_filters_still_apply() { assert!(queried.rows.iter().all(|row| row.id.starts_with("1.21"))); } -/// 空快照 = 加载态;过滤掉所有行 = 空态。两者都**不许**是白屏。 -/// -/// 抓得住:把「还没拉到」画成「没有版本」(谎报空态),或反过来。 +/// 空快照 = 加载态;过滤掉所有行 = 空态(抓「把还没拉到画成没有版本」,或反过来)。 /// 抓不住:真机上卡在加载态多久(没有超时信号,见模块文档的缺口表)。 #[test] fn loading_and_empty_are_different_states() { @@ -213,31 +207,39 @@ fn category_commands_are_real() { // --------------------------------------------------------------- 文案(i18n) -/// 中英两套目录都能把这一页的每个 key 求出来(缺译会露出 `⟪missing⟫`)。 +/// 每个 `Download*` key 都**真的被页面引用**(死 key 守门,M94 r1 / P2-6)。 /// -/// 「这一页的 key」按 `Download` 前缀取,不手写清单:清单会漏、会在下一次加 key 时不同步, -/// 而前缀遍历顺手还守住了「不许把 key 悄悄删掉」(条数下限)。 -/// -/// 抓得住:新增 key 只写了一种语言(另一套会显示 `⟪missing⟫`)。 -/// 抓不住:译文质量,以及**没有用到的** key(那是 `catalog` 自己的完整性测试)。 +/// 复审抓到的 `DownloadSelected` 就是这条要拦的东西:目录里定义了、页面没人用。所以这里把页面 +/// 源码用 `include_str!` 编进来扫一遍。**抓不住什么也说清**:反向(引用了不存在的 key)是编译错误, +/// 不需要测试;译文对不对、两套语言都填了没由 `i18n` 自己的 `built_in_catalogs_have_every_key` 守; +/// 它只看字面出现,用宏拼 key 名能骗过它。 #[test] -fn every_download_key_exists_in_both_catalogs() { - let download_keys: Vec = Key::ALL - .iter() - .copied() - .filter(|key| key.name().starts_with("Download")) - .collect(); - assert!(download_keys.len() >= 25, "下载页的 key 不该被悄悄删掉"); +fn every_download_key_is_referenced_by_the_page() { + const SOURCES: [&str; 6] = [ + include_str!("../download.rs"), + include_str!("model.rs"), + include_str!("rows.rs"), + include_str!("install.rs"), + include_str!("states.rs"), + include_str!("demo.rs"), + ]; - for lang in [Lang::En, Lang::ZhCn] { - let catalog = lang.catalog(); - for key in &download_keys { - let text = catalog.text(*key); - assert_ne!(text, unlucky_i18n::catalog::MISSING, "{key:?} 缺译"); - assert!(!text.is_empty(), "{key:?} 是空串"); - } - assert!(catalog.missing().is_empty(), "内置目录不许有缺译"); + let mut checked = 0; + for key in Key::ALL + .iter() + .filter(|key| key.name().starts_with("Download")) + { + let needle = format!("Key::{}", key.name()); + assert!( + SOURCES.iter().any(|source| source.contains(&needle)), + "{needle} 在目录里定义了,但下载页没有任何地方用它(死 key,删掉或接上)" + ); + checked += 1; } + assert!( + checked > 20, + "下载页的 key 不该被成批删掉(只查到 {checked} 条)" + ); } /// 页面级的文案全部按 `Catalog` 走:中文目录出中文,英文目录出英文,**且不含 core 的中文业务串**。 @@ -360,10 +362,7 @@ fn a_failed_install_stays_visible() { assert_eq!(model.stage_label, "Install failed"); } -/// 演示数据自己也得守规矩(它是出图的基线)。 -/// -/// 抓得住:演示数据里混进中文字面量、或行数与分类对不上。 -/// 抓不住:图上好不好看。 +/// 演示数据自己也得守规矩(它是出图的基线):抓中文字面量混入 / 行数与分类对不上。 #[test] fn the_demo_data_is_sane() { let catalog = Lang::En.catalog(); diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index d2d6318..4232026 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -119,8 +119,11 @@ catalog! { // --- download page (M94) --- // 下载页(`crates/app-iced/src/pages/download`)。一句话一个 key:带变量的句子用 `{name}`, // 页面只做 `Catalog::render`,不拼片段(`bridge` 的规矩 1)。 + // + // 文案口径(M94 r1 / P2-2):**只说这个页面真能做到的动作**(切分类、刷新、看日志), + // 不写「稍后会自动重试」这类 UI 没有的能力,也不拿文案去补功能。 DownloadTitle => ("Downloads", "下载"), - DownloadSubtitle => ("Version list from the official manifest", "来自官方版本清单的版本列表"), + DownloadSubtitle => ("Versions you can install", "可以安装的版本"), DownloadRefresh => ("Refresh", "刷新"), DownloadInstall => ("Install", "安装"), DownloadCancel => ("Cancel install", "取消安装"), @@ -128,16 +131,15 @@ catalog! { DownloadCount => ("{count} versions", "{count} 个版本"), DownloadRowSupporting => ("{kind} · released {date}", "{kind} · 发布于 {date}"), DownloadSelectHint => ("Pick a version, then install", "选一个版本再安装"), - DownloadSelected => ("Selected {version}", "已选 {version}"), - DownloadLoading => ("Loading the version list…", "正在加载版本列表…"), + DownloadLoading => ("Loading versions…", "正在加载版本列表…"), DownloadLoadingHint => ( - "The list appears after the first successful fetch; this page never goes blank.", - "首次拉取成功后这里会出现版本列表;这一页不会是一片白。" + "If this stays here, press Refresh in the top right.", + "如果一直停在这里,点右上角的刷新。" ), - DownloadEmptyTitle => ("No matching versions", "没有匹配的版本"), + DownloadEmptyTitle => ("No versions to show", "没有可显示的版本"), DownloadEmptyBody => ( - "Switch the category or clear the filter to see more.", - "换一个分类或清掉筛选后再看。" + "Pick another category, or refresh the list.", + "换一个分类,或刷新一次列表。" ), DownloadCategoryAll => ("All", "全部"), DownloadCategoryRelease => ("Release", "正式版"), From 6453c767501775d952b3c7ef96eca292f432d1b1 Mon Sep 17 00:00:00 2001 From: tower Date: Mon, 2 Nov 2026 21:04:15 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(download):=20M94=20r2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=AE=89=E8=A3=85=E7=BB=88=E6=80=81=E5=87=BA?= =?UTF-8?q?=E5=8F=A3=20+=20=E5=88=86=E7=B1=BB=E7=9C=9F=E6=9C=BA=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=EF=BC=88=E5=A4=8D=E5=AE=A1=20P1-1=20/=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1 安装一旦收尾就再也装不了第二个版本(终态面板没有出口) - 页面侧:`Download::from_snapshot` 把 **Done / Cancelled** 当结束(`InstallView::finished`), 面板让回列表 —— 装完一个版本可以直接选下一个继续装(图:r2-done / r2-done-next)。 不清 `Failed`:失败态带着用户要的东西(停在哪个文件 + 去日志页)。 - 面板加真出口:失败态「Open logs」(实心)+「Dismiss」(文本 → `Message::CancelInstall`, host 侧对没在跑的任务是安全 no-op),修掉「失败后连重试都不可达」。 - 决策抽成纯函数 `install::panel_state()`(Hint / Ready / Install),`panel()` 只是它的画法, 于是「能不能再装」可以被单测钉死,不用开图形环境。 - 两条新测试**不走 MockBackend**(它从不发 `InstallProgress`,终态永远跑不到 ⇒ 假绿): 直接构造 `AppState` + `apply(CoreEvent::InstallProgress(..))`。 P2-1 分类 chip 在真 host 上是空控件 - `from_snapshot(state, category, catalog)` 收分类并按 `Category::matches` **本地过滤** (真 host 的 `ListVersions(_)` 丢掉分类参数、回全量清单); - 分类不再从列表反推(`Category::of` 只留给演示首帧),`app/view.rs::model` 收分类、 `refresh_view` 把上一份视图模型的分类带回去 ⇒ 事件回来 chip 不会跳回「全部」; - `update.rs` 的 `SetCategory` 写分类后**立刻重建**(chip 与列表同一个谓词),再派真指令。 p3(顺带) - 失败态与占位态共存:列表空/加载中时若安装还在跑,面板照样出现在底部(r2-installing-empty); - 修 `install.rs` 的坏 rustdoc 链接;`install.rs` / `model.rs` 的测试模块外置到子文件守住 400 行。 测试文件:`pages/download/{model,install}/tests.rs`(新),`pages/download/tests.rs` 里的 `from_snapshot` 调用补分类参数。 实证(回退法):删掉 `.filter(|view| !view.finished())` → 两条终态测试红并点名 「Done 之后不许再占着安装面板」;删掉 `.filter(|category.matches(..))` → 两条分类测试红 (「只剩正式版」left=3 right=1)。放回修复 → 全绿。 门禁:fmt --check 干净、clippy -D warnings 干净、app-iced 280 + 集成 6 + i18n 23 全绿(-j 4)。 --- crates/app-iced/src/app/state.rs | 5 +- crates/app-iced/src/app/update.rs | 8 +- .../src/app/update/download_wiring.rs | 9 ++ crates/app-iced/src/app/view.rs | 9 +- crates/app-iced/src/pages/download.rs | 54 +++++--- crates/app-iced/src/pages/download/demo.rs | 93 +++++++++++--- crates/app-iced/src/pages/download/install.rs | 90 +++++++++---- .../src/pages/download/install/tests.rs | 118 ++++++++++++++++++ crates/app-iced/src/pages/download/model.rs | 39 +++++- .../src/pages/download/model/tests.rs | 62 +++++++++ crates/app-iced/src/pages/download/tests.rs | 18 +-- crates/i18n/src/catalog.rs | 1 + 12 files changed, 432 insertions(+), 74 deletions(-) create mode 100644 crates/app-iced/src/pages/download/install/tests.rs create mode 100644 crates/app-iced/src/pages/download/model/tests.rs diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index e762648..13e33d3 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -258,7 +258,10 @@ impl App { /// /// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。 pub(super) fn refresh_view(&mut self) { - self.view = super::view::model(&self.state, self.width, self.options.force_state); + // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): + // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 + let category = self.view.download.category; + self.view = super::view::model(&self.state, self.width, self.options.force_state, category); self.view_dirty = false; self.refreshes += 1; } diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index ab21dd4..da98614 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -289,8 +289,14 @@ impl App { pages::Message::InstallVersion(id) => self.dispatch(AppCommand::InstallVersion(id)), pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall), pages::Message::SetCategory(category) => { - // chip 立刻反映选中(视图模型),列表由后端回 `VersionListChanged` 换掉 + // 分类是**本页的过滤条件**,不是后端事实的乐观回显:真 host 会把 + // `ListVersions` 的分类参数丢掉、回全量清单(复审 P2-1),所以过滤在本页做。 + // 写进视图模型 → 立刻按它重建(`refresh_view` 会把 category 带回去并据此过滤行), + // 于是 chip 与列表用同一个谓词,不存在「chip 说正式版、列表是全量」的两份真相。 self.view.download.category = category; + self.view_dirty = true; + self.refresh_view(); + // 列表内容仍然由后端给:真指令照派(presenter 修好 `spawn_list` 之后它就是那一路) if let Some(kind) = category.command() { self.dispatch(AppCommand::ListVersions(kind)); } diff --git a/crates/app-iced/src/app/update/download_wiring.rs b/crates/app-iced/src/app/update/download_wiring.rs index 38cc095..c5c06de 100644 --- a/crates/app-iced/src/app/update/download_wiring.rs +++ b/crates/app-iced/src/app/update/download_wiring.rs @@ -62,4 +62,13 @@ fn download_intents_dispatch_real_commands() { 0, "recorder 不回事件 ⇒ 列表不该冒出来" ); + // 分类是本地过滤条件:**任何一次视图重建都不许把它打回 `All`**(复审 P2-1 的回归点)。 + // 抓得住:`refresh_view` 忘记把分类带回去(那一行删掉这条就红)。 + // 抓不住:真 host 是否按分类拉清单(那是 presenter 的 `spawn_list`,已报 finding)。 + app.refresh_view(); + assert_eq!( + app.view.download.category, + pages::Category::Release, + "重建视图模型之后分类必须还在" + ); } diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index bda3715..b418390 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -84,6 +84,7 @@ pub(super) fn model( state: &AppState, width: f32, forced: Option, + category: pages::Category, ) -> pages::Model { let route = state.route().clone(); let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances); @@ -113,7 +114,7 @@ pub(super) fn model( window_width: width, active: route.nav(), instances: pages::Instances { rows, detail }, - download: pages::Download::from_snapshot(state, unlucky_i18n::Lang::En.catalog()), + download: pages::Download::from_snapshot(state, category, unlucky_i18n::Lang::En.catalog()), forced: forced.map(|state| match state { crate::cli::ForcedState::Hover => Interaction::Hovered, crate::cli::ForcedState::Pressed => Interaction::Pressed, @@ -248,7 +249,7 @@ mod tests { )); state.navigate(Route::Detail("survival".to_string())); - let model = model(&state, 1180.0, None); + let model = model(&state, 1180.0, None, pages::Category::All); assert_eq!(model.screen, pages::Screen::Instances); assert_eq!(model.active, Some(pages::Screen::Instances.nav())); assert_eq!(model.window_width, 1180.0); @@ -279,7 +280,7 @@ mod tests { )); state.navigate(Route::Detail("survival".to_string())); - let model = model(&state, 1180.0, None); + let model = model(&state, 1180.0, None, pages::Category::All); assert_eq!( model.instances.rows[0].status.as_deref(), Some("Downloading · 50%") @@ -317,7 +318,7 @@ mod tests { fn the_agent_route_has_no_page() { let mut state = AppState::new(); state.navigate(Route::Agent); - let model = model(&state, 1180.0, None); + let model = model(&state, 1180.0, None, pages::Category::All); // Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支) assert_eq!(pages::Screen::of(state.route()), None); assert!(model.instances.detail.is_none()); diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index 9db5fbe..38ceca2 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -108,24 +108,42 @@ fn body<'a>( catalog: Catalog, model: &'a Download, ) -> Element<'a, Message> { - let content = if model.is_loading() { - states::loading(ui, catalog) - } else if model.is_empty() { - states::empty(ui, catalog) - } else { - 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() - }; + if model.is_loading() || model.is_empty() { + let placeholder = if model.is_loading() { + states::loading(ui, catalog) + } else { + states::empty(ui, catalog) + }; + // 占位状态下**也**把安装面板带上(复审 p3):列表空/还没到,不代表安装停了—— + // 没有这一段的话,安装进行中一旦列表被过滤空,进度条会凭空消失。 + let content: Element<'a, Message> = if model.install.is_some() { + column![ + placeholder, + divider::full(ui), + install::panel(ui, catalog, model) + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .height(Length::Fill) + .into() + } else { + placeholder + }; + return frame::single_column(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(); frame::single_column(metrics, content) } diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index fc20016..cf71d33 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -18,13 +18,25 @@ //! --screen=download --screenshot=out.png --screenshot-theme=light --software-renderer //! ``` //! -//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `installing` / `installing-late` / `failed`。 +//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `category-release` / `installing` / +//! `installing-late` / `installing-empty` / `failed` / `done` / `done-next`。 +//! +//! `category-release` / `done` / `done-next` 都**不手搓结果**,而是拿一份 `AppState` 跑 +//! [`Download::from_snapshot`]:图证明的是产品路径的行为(分类本地过滤、装完后面板让回列表), +//! 不是演示的特权。 +//! +//! `done` / `done-next` 是「**刚装完**」的两个采样点:它们不手搓模型,而是拿一份 +//! `AppState`(版本列表 + `InstallStage::Done`)跑 [`Download::from_snapshot`],所以图上看到的是 +//! 产品路径的真行为(复审 p2:`Done` 之后面板必须让回列表,否则第二个版本装不了)。 //! **只有 harness 与演示走这里**:产品路径由 `app/view.rs` 从 presenter 快照建模型。 use unlucky_presenter::dto::{ AncientKind, InstallProgressView, InstallStage, VersionKind, VersionSummary, }; +use unlucky_presenter::dto::{AppEvent, CoreEvent}; +use unlucky_presenter::state::AppState; + use unlucky_i18n::Catalog; use super::model::{Category, Download, InstallView, VersionRow}; @@ -60,6 +72,17 @@ pub fn demo(catalog: Catalog) -> Download { selected: Some("1.21.9".to_string()), ..ready(catalog) }, + // 装完之后:走**产品那条真转换**(见 `finished`),图里看到的就是页面真会画的东西 + Some("done") => finished(catalog, None), + // 装完之后再点下一个版本:面板必须重新给出实心「安装」 + Some("done-next") => finished(catalog, Some("1.21.9-pre3")), + // 安装进行中、但列表被过滤空:面板要跟着占位态一起在(复审 p3 的边角) + // 分类筛选(本页本地过滤:真 host 丢弃 ListVersions 的分类参数)——混合清单里只留正式版 + Some("category-release") => category(catalog, Category::Release), + Some("installing-empty") => Download { + install: Some(install(InstallStage::Downloading, 0.42, catalog)), + ..Download::loading() + }, _ => ready(catalog), } } @@ -69,6 +92,24 @@ pub fn demo(catalog: Catalog) -> Download { /// 不复用 `mock_data::mock_versions()`:演示数据是**出图的基线**,得比 mock 稳定 /// (mock 随 P3 的假数据一起漂)。 fn ready(catalog: Catalog) -> Download { + let versions = demo_versions(); + let rows: Vec = versions + .iter() + .map(|version| VersionRow::of(version, catalog)) + .collect(); + + Download { + // 混合分类 → 没有单个 chip 该亮(这正是 `Category::of` 的反推结果) + category: Category::of(&versions), + rows, + selected: None, + install: None, + loaded: true, + } +} + +/// 演示用的版本表(`ready` 与 `finished` 共用;与 presenter 的 `mock_versions` 同形)。 +fn demo_versions() -> Vec { fn summary(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary { VersionSummary { id: id.to_string(), @@ -78,7 +119,7 @@ fn ready(catalog: Catalog) -> Download { } } - let versions = vec![ + 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), @@ -95,21 +136,43 @@ fn ready(catalog: Catalog) -> Download { "2010-08-11", false, ), - ]; + ] +} - let rows: Vec = versions - .iter() - .map(|version| VersionRow::of(version, catalog)) - .collect(); +/// 「刚装完一个版本」的演示:**走产品那条真转换**(`AppState` → `Download::from_snapshot`)。 +/// +/// 为什么不用手搓模型:本轮复审的 bug 正是「`Done` 之后 `install` 从不清」——手搓一个 +/// `install: None` 的模型会让图**假绿**(图上好看,真路径照样卡死)。这里真的走一遍快照转换, +/// 于是图能证明「装完之后面板让回列表」是产品路径的行为,不是演示的特权。 +/// +/// `select_next`:演「用户接着点了下一个版本」,面板应当重新给出实心「安装」。 +fn finished(catalog: Catalog, select_next: Option<&str>) -> Download { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged( + demo_versions(), + ))); + state.apply(AppEvent::Core(CoreEvent::InstallProgress(Some( + InstallProgressView { + version: "1.21.8".to_string(), + stage: InstallStage::Done, + files_done: 512, + files_total: 512, + ..InstallProgressView::new() + }, + )))); - Download { - // 混合分类 → 没有单个 chip 该亮(这正是 `Category::of` 的反推结果) - category: Category::of(&versions), - rows, - selected: None, - install: None, - loaded: true, - } + let mut model = Download::from_snapshot(&state, Category::All, catalog); + model.selected = select_next.map(str::to_string); + model +} + +/// 「用户点了某个分类」的演示:走真转换(`from_snapshot` 的分类过滤),不手搓过滤结果。 +fn category(catalog: Catalog, wanted: Category) -> Download { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged( + demo_versions(), + ))); + Download::from_snapshot(&state, wanted, catalog) } /// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。 diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index 834dcbf..b78ac36 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -11,7 +11,7 @@ //! |---|---|---| //! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` | //! | 「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` | -//! | 「打开日志」 | [`Message::Navigate(Route::Logs)`] | `AppState::navigate`(不派指令) | +//! | 「打开日志」 | [`Message::Navigate`](`Route::Logs`) | `AppState::navigate`(不派指令) | //! //! 按钮按下**不改任何本地状态**:进度条读的是 `model.install`,它只由 presenter 的 //! `InstallProgress` 事件灌进来(`apply` → 视图模型重建)。于是「点了没变」= 后端还没回事件。 @@ -46,16 +46,51 @@ use crate::theme::Ui; use super::super::frame; use super::super::Message; -use super::model::{Download, InstallView, VersionRow}; +use super::model::{Download, InstallView}; + +/// 面板要画哪一种形态(**决策与画法分开**:决策是纯函数,单测直接钉它)。 +/// +/// 分开的理由就是本轮复审那件事:「装完之后面板还占着、用户再也装不了第二个版本」—— +/// 这是**决策**错了,画法本身没错;把决策抽出来,测试才能在不开图形环境的情况下断言它。 +#[derive(Debug, Clone, PartialEq)] +pub(super) enum PanelState<'a> { + /// 没有安装、也没选中:一句「选一个版本再安装」 + Hint, + /// 选中了一个版本:给出「安装」(已装的给禁用态) + Ready { + /// 选中的版本 id(= `AppCommand::InstallVersion` 的载荷) + id: &'a str, + /// 本地已经装过(按钮禁用,列表行上也有「已安装」+ 勾) + installed: bool, + }, + /// 有安装(进行中 / 失败):进度或错误面板 + Install(&'a InstallView), +} + +/// 面板此刻该画哪一种(纯函数,无 iced)。 +/// +/// 顺序是**安装优先**:有安装时面板讲安装,讲不了列表;安装结束后 `model.install` 由 +/// [`Download::from_snapshot`](super::model::Download::from_snapshot) 清掉,于是这里自然回到 +/// [`PanelState::Ready`] / [`PanelState::Hint`],用户能接着选下一个版本。 +pub(super) fn panel_state(model: &Download) -> PanelState<'_> { + match &model.install { + Some(install) => PanelState::Install(install), + None => match model.selected_row() { + Some(row) => PanelState::Ready { + id: row.id.as_str(), + installed: row.installed, + }, + None => PanelState::Hint, + }, + } +} /// 底部的安装面板。 pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> { - let content = match &model.install { - Some(install) => installing(ui, catalog, install), - None => match model.selected_row() { - Some(entry) => ready(ui, catalog, entry), - None => hint(ui, catalog), - }, + let content = match panel_state(model) { + PanelState::Install(install) => installing(ui, catalog, install), + PanelState::Ready { id, installed } => ready(ui, catalog, id, installed), + PanelState::Hint => hint(ui, catalog), }; container(content) @@ -79,24 +114,24 @@ fn hint<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { /// /// 已装的版本给**禁用**的 Outlined 按钮(`Button::disabled` 只清消息、保留几何): /// 「已装」是列表行上的事实(overline + 勾),按钮再点一次不会更清楚。 -fn ready<'a>(ui: &'a Ui, catalog: Catalog, entry: &'a VersionRow) -> Element<'a, Message> { +fn ready<'a>(ui: &'a Ui, catalog: Catalog, id: &'a str, installed: bool) -> Element<'a, Message> { let label = container(frame::text_role( ui, TypeRole::BodyMedium, false, - entry.id.as_str(), + id, ui.color(Role::OnSurface), )) .width(Length::Fill); - let action = if entry.installed { + let action = if installed { buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstall))) } else { buttons::filled( ui, Button::new( catalog.text(Key::DownloadInstall), - Message::InstallVersion(entry.id.clone()), + Message::InstallVersion(id.to_string()), ) .leading(IconKind::Download), ) @@ -200,14 +235,24 @@ fn failed<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element ui.color(Role::OnErrorContainer), ); - let action = buttons::filled( - ui, - Button::new( - catalog.text(Key::DownloadOpenLogs), - Message::Navigate(Route::Logs), - ) - .leading(IconKind::Logs), - ); + let actions = row![ + buttons::filled( + ui, + Button::new( + catalog.text(Key::DownloadOpenLogs), + Message::Navigate(Route::Logs), + ) + .leading(IconKind::Logs), + ), + // 失败把面板钉住了:不给出口的话用户既重试不了、也装不了别的版本(只能重启)。 + // `CancelInstall` 在 host 侧就是「清 install + 发 InstallProgress(None)」,对没在跑的任务是安全 no-op。 + buttons::text( + ui, + Button::new(catalog.text(Key::DownloadDismiss), Message::CancelInstall), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); container( column![ @@ -220,7 +265,7 @@ fn failed<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element catalog.text(Key::DownloadFailedHint), ui.color(Role::OnErrorContainer), ), - action, + actions, ] .spacing(ui.space(Space::X2)) .width(Length::Fill), @@ -237,3 +282,6 @@ fn failed<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element }) .into() } + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/download/install/tests.rs b/crates/app-iced/src/pages/download/install/tests.rs new file mode 100644 index 0000000..02d159e --- /dev/null +++ b/crates/app-iced/src/pages/download/install/tests.rs @@ -0,0 +1,118 @@ +//! 安装面板的决策测试(M94 r2)。 +//! +//! 从 `install.rs` 外置(那边加上本轮修复会顶到 400 行)。`use super::*` 仍指向 +//! `crate::pages::download::install`,`PanelState` / `panel_state` 是那里的 `pub(super)`。 +//! +//! **不走 `MockBackend`**:`MockBackend::script_install` 只发 Dialog + Toast、从不发 +//! `InstallProgress`(复审 r1 的取证),所以 mock 下 `install` 恒为 `None`,终态分支永远跑不到—— +//! 用 mock 写的测试会**假绿**。这里直接构造 `AppState` 并 `apply(CoreEvent::InstallProgress(..))`。 + +use unlucky_presenter::dto::{ + AppEvent, CoreEvent, InstallProgressView, InstallStage, VersionKind, VersionSummary, +}; +use unlucky_presenter::state::AppState; + +use unlucky_i18n::Lang; + +use super::super::model::{Category, Download}; +use super::{panel_state, PanelState}; + +fn version(id: &str) -> VersionSummary { + VersionSummary { + id: id.to_string(), + kind: VersionKind::Release, + released: "2026-09-20".to_string(), + installed: false, + } +} + +fn progress(stage: InstallStage) -> InstallProgressView { + InstallProgressView { + version: "1.21.9".to_string(), + stage, + files_done: 512, + files_total: 512, + ..InstallProgressView::new() + } +} + +fn snapshot(stage: InstallStage) -> AppState { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged(vec![ + version("1.21.9"), + version("1.21.8"), + ]))); + state.apply(AppEvent::Core(CoreEvent::InstallProgress(Some(progress( + stage, + ))))); + state +} + +/// **装完之后页面必须回到「能再装下一个」**(复审 p2 的修复验收)。 +/// +/// 复审现象:`InstallDone` 从不清 `AppState.install`(presenter 只在取消时清),而面板**优先** +/// 用 `install`,终态又没有可执行动作 ⇒ 装完第一个版本后按钮永久消失,只能重启进程。 +/// +/// 抓得住:`Done` 之后面板还被 `install` 占着(= 装不了第二个版本的根因)、列表没回来、 +/// 面板不再给「安装」。测试直接钉 [`panel_state`]——那是 `panel()` 唯一的决策入口,两者不会漂。 +/// 抓不住:presenter 侧 `AppState.install` 仍是 `Some(Done)`(proper fix 不在本 mission,已报 finding); +/// 也抓不住「装完后已装标记不刷新」(同一个 finding)。 +#[test] +fn a_finished_install_gives_the_page_back_to_the_list() { + let running = Download::from_snapshot( + &snapshot(InstallStage::Downloading), + Category::All, + Lang::En.catalog(), + ); + assert!(running.install.is_some(), "跑着的时候面板要在"); + assert!(matches!(panel_state(&running), PanelState::Install(_))); + + let state = snapshot(InstallStage::Done); + assert_eq!( + state.install.as_ref().map(|view| view.stage), + Some(InstallStage::Done), + "现实如此:快照里仍留着终态(presenter 不清),页面必须自己让位" + ); + + let model = Download::from_snapshot(&state, Category::All, Lang::En.catalog()); + assert!(model.install.is_none(), "Done 之后不许再占着安装面板"); + assert!(model.selected.is_none(), "装完不替用户选中任何行"); + assert_eq!(model.rows.len(), 2, "列表要回来"); + assert_eq!( + panel_state(&model), + PanelState::Hint, + "回到「选一个版本再安装」" + ); + + // 再装一次:选中下一个版本 → 面板重新给出「安装」(= `Message::InstallVersion(id)`) + let mut next = model.clone(); + next.selected = Some("1.21.9".to_string()); + assert_eq!( + panel_state(&next), + PanelState::Ready { + id: "1.21.9", + installed: false + }, + "装完之后必须能接着装下一个版本" + ); +} + +/// 取消与失败**不许**一起被清:失败态是 P2-4 的错误出口,取消是「已经没事了」。 +/// +/// 抓得住:把 `finished()` 写成「terminated()」(那会把失败态的日志入口一起清掉,用户失去原因)。 +/// 抓不住:presenter 在取消后是否补发 `InstallProgress(None)`(那是 presenter 的行为)。 +#[test] +fn only_done_and_cancelled_release_the_panel() { + let failed = Download::from_snapshot( + &snapshot(InstallStage::Failed), + Category::All, + Lang::En.catalog(), + ); + assert!(failed.install.is_some(), "失败态要留在屏幕上"); + assert!(matches!(panel_state(&failed), PanelState::Install(_))); + + for stage in [InstallStage::Done, InstallStage::Cancelled] { + let model = Download::from_snapshot(&snapshot(stage), Category::All, Lang::En.catalog()); + assert!(model.install.is_none(), "{stage:?} 之后面板要让位"); + } +} diff --git a/crates/app-iced/src/pages/download/model.rs b/crates/app-iced/src/pages/download/model.rs index ce7b5fe..632087f 100644 --- a/crates/app-iced/src/pages/download/model.rs +++ b/crates/app-iced/src/pages/download/model.rs @@ -231,6 +231,14 @@ impl InstallView { self.stage.is_terminal() } + /// **结束且没什么可说**的收尾:成功(`Done`)与取消(`Cancelled`)。 + /// + /// 这两种终态页面没有动作可给,留着只会让安装面板停在一个点不动的状态上(复审 p2 的根因)。 + /// 失败**不在其中**:它还要给原因出口。 + pub fn finished(&self) -> bool { + matches!(self.stage, InstallStage::Done | InstallStage::Cancelled) + } + /// 还在跑(「取消安装」只在这时给)。 pub fn running(&self) -> bool { !self.stage.is_terminal() @@ -273,13 +281,21 @@ impl Download { /// presenter 快照 → 下载页模型(**唯一的真数据入口**)。 /// - /// 快照里的两个过滤条件(`installed_only` / `download_query`)原样照用:它们是 presenter 的 - /// 状态,页面不该另存一份;过滤后为空就是「空态」(`loaded` 仍为 `true`)。 - pub fn from_snapshot(state: &AppState, catalog: Catalog) -> Self { + /// 三个过滤条件都是「过滤」,不是「乐观回显」: + /// - `installed_only` / `download_query` 是 presenter 自己的状态,页面不该另存一份; + /// - `category` 是**本页的过滤条件**(参数传进来,调用方持有它):真 host 的 + /// `AppCommand::ListVersions(_)` 会丢掉分类参数、回全量清单(见 `host/dispatch.rs`), + /// 所以分类**必须在页面这一侧过滤**,否则 chip 只是点亮一下、列表照旧是全量(复审 P2-1)。 + /// 过滤谓词与 chip 的选中判断是同一个 [`Category::matches`],于是「chip 说的」与「列表画的」 + /// 永远是同一份真相,事件回来重建也不会跳回「全部」。 + /// + /// 过滤后为空就是「空态」(`loaded` 仍为 `true`)。 + pub fn from_snapshot(state: &AppState, category: Category, catalog: Catalog) -> Self { let query = state.download_query.trim().to_ascii_lowercase(); 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)) .map(|version| VersionRow::of(version, catalog)) @@ -288,13 +304,23 @@ impl Download { let install = state .install .as_ref() - .map(|view| InstallView::of(view, catalog)); + .map(|view| InstallView::of(view, catalog)) + // 安装**已经成功结束/被取消**就不再占用面板(复审 p2,本轮的修复点): + // presenter 只在 `InstallProgress(None)`(取消指令)时清 `AppState.install`,`Done` + // 会一直留在快照里 ⇒ 面板被一个**没有动作**的终态钉死,用户装完第一个版本就再也发不出 + // 第二次安装(只能重启进程)。界面这里把「结束」当结束:面板让回列表。 + // + // 为什么不连 `Failed` 一起清:失败态带着用户要的东西(停在哪个文件 + 去日志页), + // 那正是 P2-4 要它一眼可辨的理由。 + .filter(|view| !view.finished()); // 快照没有「选中」字段:正在安装的那个版本就是「选中」(见模块文档的缺口表)。 let selected = install.as_ref().map(|view| view.version.clone()); Self { rows, - category: Category::of(&state.versions), + // 分类不再从列表反推(那正是「chip 跳回全部」的来源):谁选的就是谁。 + // [`Category::of`] 只留给「不经过用户选择」的入口(演示数据的首帧)。 + category, selected, install, loaded: !state.versions.is_empty(), @@ -317,3 +343,6 @@ impl Download { self.loaded && self.rows.is_empty() } } + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/download/model/tests.rs b/crates/app-iced/src/pages/download/model/tests.rs new file mode 100644 index 0000000..a5a7e4d --- /dev/null +++ b/crates/app-iced/src/pages/download/model/tests.rs @@ -0,0 +1,62 @@ +//! 视图模型的判据(从 `model.rs` 外置:那边加上本轮的分类过滤会顶到 400 行)。 + +use unlucky_presenter::dto::{AncientKind, AppEvent, CoreEvent, VersionKind, VersionSummary}; +use unlucky_presenter::state::AppState; + +use unlucky_i18n::Lang; + +use super::{Category, Download}; + +fn version(id: &str, kind: VersionKind) -> VersionSummary { + VersionSummary { + id: id.to_string(), + kind, + released: "2026-09-20".to_string(), + installed: false, + } +} + +fn state_with(versions: Vec) -> AppState { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions))); + state +} + +/// 分类筛选是**本页的过滤**(复审 P2-1:真 host 的 `ListVersions(_)` 丢掉分类参数、回全量清单)。 +/// +/// 抓得住:`from_snapshot` 忘了按分类过滤——那正是「chip 点亮一下、列表照旧全量、事件回来 chip +/// 跳回『全部』」的根因;也抓得住「分类被列表反推覆盖」(`Category::of` 回到 `All`)。 +/// 抓不住:真 host 是否按分类拉清单(`host/dispatch.rs:30` 丢参数,已报 finding)。 +#[test] +fn the_category_filters_the_list_locally() { + let state = state_with(vec![ + version("1.21.9", VersionKind::Release), + version("1.21.9-pre3", VersionKind::Snapshot), + version("rd-132211", VersionKind::Ancient(AncientKind::Classic)), + ]); + + let release = Download::from_snapshot(&state, Category::Release, Lang::En.catalog()); + assert_eq!( + release.category, + Category::Release, + "谁选的就是谁:不从列表反推(反推会把混合列表打回 All)" + ); + assert_eq!(release.rows.len(), 1, "只剩正式版"); + assert_eq!(release.rows[0].id, "1.21.9"); + + let all = Download::from_snapshot(&state, Category::All, Lang::En.catalog()); + assert_eq!(all.rows.len(), 3, "「全部」不过滤"); +} + +/// 分类把行全滤掉时是**空态**,不是加载态(两态文案与位置不同,别混)。 +/// +/// 抓得住:把「筛出来是空」当成「还没拉到」。 +/// 抓不住:用户看到空态之后会怎么操作(那是 UI 层)。 +#[test] +fn a_category_that_hides_everything_is_the_empty_state() { + let state = state_with(vec![version("1.21.9", VersionKind::Release)]); + let ancient = Download::from_snapshot(&state, Category::Ancient, Lang::En.catalog()); + assert!(ancient.rows.is_empty()); + assert!(!ancient.is_loading(), "快照里有版本 ⇒ 不是加载中"); + assert!(ancient.is_empty(), "筛掉所有行 ⇒ 空态"); +} diff --git a/crates/app-iced/src/pages/download/tests.rs b/crates/app-iced/src/pages/download/tests.rs index 2dd580d..64fa419 100644 --- a/crates/app-iced/src/pages/download/tests.rs +++ b/crates/app-iced/src/pages/download/tests.rs @@ -93,7 +93,7 @@ fn the_snapshot_becomes_the_page_model() { }, )))); - let model = Download::from_snapshot(&state, Lang::En.catalog()); + let model = Download::from_snapshot(&state, Category::All, Lang::En.catalog()); assert_eq!(model.rows.len(), 2); assert_eq!(model.rows[0].id, "1.21.9"); @@ -101,8 +101,8 @@ fn the_snapshot_becomes_the_page_model() { assert!(!model.rows[0].installed); assert_eq!( model.category, - Category::Release, - "整份列表都是正式版 → 反推出正式版分类" + Category::All, + "分类是**调用方给的过滤条件**,不再从列表反推(反推正是 chip 跳回「全部」的来源)" ); assert_eq!( model.selected.as_deref(), @@ -134,14 +134,14 @@ fn presenter_filters_still_apply() { let installed_only = { state.installed_only = true; - Download::from_snapshot(&state, Lang::En.catalog()) + Download::from_snapshot(&state, Category::All, Lang::En.catalog()) }; assert_eq!(installed_only.rows.len(), 2, "仅已装"); let queried = { state.installed_only = false; state.download_query = " 1.21 ".to_string(); - Download::from_snapshot(&state, Lang::En.catalog()) + Download::from_snapshot(&state, Category::All, Lang::En.catalog()) }; assert_eq!(queried.rows.len(), 2, "版本号片段匹配两个"); assert!(queried.rows.iter().all(|row| row.id.starts_with("1.21"))); @@ -151,12 +151,12 @@ fn presenter_filters_still_apply() { /// 抓不住:真机上卡在加载态多久(没有超时信号,见模块文档的缺口表)。 #[test] fn loading_and_empty_are_different_states() { - let loading = Download::from_snapshot(&AppState::new(), Lang::En.catalog()); + let loading = Download::from_snapshot(&AppState::new(), Category::All, Lang::En.catalog()); assert!(loading.is_loading() && !loading.is_empty()); let mut state = state_with(vec![version("1.21.9", VersionKind::Release, false)]); state.download_query = "no-such-version".to_string(); - let empty = Download::from_snapshot(&state, Lang::En.catalog()); + let empty = Download::from_snapshot(&state, Category::All, Lang::En.catalog()); assert!(!empty.is_loading(), "列表拉到了,不是加载中"); assert!(empty.is_empty(), "过滤后没有行 = 空态"); } @@ -252,8 +252,8 @@ fn the_model_speaks_the_catalog_language() { let versions = vec![version("1.21.9", VersionKind::Release, false)]; let state = state_with(versions); - let en = Download::from_snapshot(&state, Lang::En.catalog()); - let zh = Download::from_snapshot(&state, Lang::ZhCn.catalog()); + let en = Download::from_snapshot(&state, Category::All, Lang::En.catalog()); + let zh = Download::from_snapshot(&state, Category::All, Lang::ZhCn.catalog()); assert_eq!(en.rows[0].supporting, "Release · released 2026-09-20"); assert_eq!(zh.rows[0].supporting, "正式版 · 发布于 2026-09-20"); diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index 4232026..25b4e66 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -166,6 +166,7 @@ catalog! { "完整原因在日志页。" ), DownloadOpenLogs => ("Open logs", "打开日志"), + DownloadDismiss => ("Dismiss", "关闭"), } /// 一套语言的目录(不可变;`Copy`,随手传值)。 From 5340908882acc417b4c0d9960b07ddcfea461e93 Mon Sep 17 00:00:00 2001 From: tower Date: Mon, 2 Nov 2026 21:45:40 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(download):=20rebase=20=E5=88=B0=20?= =?UTF-8?q?M93=20=E4=B9=8B=E5=90=8E=20app/update.rs=20=E7=A0=B4=20400=20?= =?UTF-8?q?=E2=86=92=20=E4=B8=8B=E8=BD=BD=E9=A1=B5=E5=9B=9B=E6=94=AF?= =?UTF-8?q?=E6=8B=86=E5=88=B0=20update/download.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M93(外观三件套接线)合入后 `app/update.rs` 从 348 涨到 410 行,再加上下载页的四个 arm 会顶破 ≤400 硬线。把下载页那几支(SelectVersion / InstallVersion / CancelInstall / SetCategory / Action::RefreshSources)挪到 `app/update/download.rs`(`app/update/**` 在本 mission scope 内),`on_page` 入口只留一句 `if self.on_download(&message) { return; }`, match 末尾补一条 `_ => {}` 做穷尽性。 行为零变化:指令、视图模型写法、`refresh_view` 顺序都照搬;M93 的 `Appearance` arm / `env_start_route` / `on_appearance` 一行没动。 行数:update.rs 410 → 397,download.rs 57。 --- crates/app-iced/src/app/update.rs | 37 +++++--------- crates/app-iced/src/app/update/download.rs | 57 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 25 deletions(-) create mode 100644 crates/app-iced/src/app/update/download.rs diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index da98614..36e0218 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -41,6 +41,8 @@ use super::message::{route_of, Message}; use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW}; use super::CAPTURE_AT_FRAME; +mod download; + impl App { /// 一条消息进来,改状态并返回后续任务。 pub(super) fn update(&mut self, message: Message) -> Task { @@ -275,6 +277,11 @@ impl App { /// 页面消息:导航走路由栈,动作翻成 `AppCommand`。 fn on_page(&mut self, message: pages::Message) { + // 下载页的四支(含「刷新下载源」)在 `update/download.rs`:本文件 M93 合入后已贴 400 行 + if self.on_download(&message) { + return; + } + match message { pages::Message::Navigate(route) => { self.state.navigate(route); @@ -284,32 +291,12 @@ impl App { pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), pages::Message::Appearance(change) => self.on_appearance(change), - // ── 下载页(M94):选中是**页面内**状态(不 dispatch),其余三条都派真指令。 - pages::Message::SelectVersion(id) => self.view.download.selected = Some(id), - pages::Message::InstallVersion(id) => self.dispatch(AppCommand::InstallVersion(id)), - pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall), - pages::Message::SetCategory(category) => { - // 分类是**本页的过滤条件**,不是后端事实的乐观回显:真 host 会把 - // `ListVersions` 的分类参数丢掉、回全量清单(复审 P2-1),所以过滤在本页做。 - // 写进视图模型 → 立刻按它重建(`refresh_view` 会把 category 带回去并据此过滤行), - // 于是 chip 与列表用同一个谓词,不存在「chip 说正式版、列表是全量」的两份真相。 - self.view.download.category = category; - self.view_dirty = true; - self.refresh_view(); - // 列表内容仍然由后端给:真指令照派(presenter 修好 `spawn_list` 之后它就是那一路) - if let Some(kind) = category.command() { - self.dispatch(AppCommand::ListVersions(kind)); - } - } // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) - pages::Message::Action(action) => match action { - // 「刷新下载源」不是占位动作:具体分类 → 重拉该分类;「全部」→ 让 host 重扫一遍 - pages::Action::RefreshSources => match self.view.download.category.command() { - Some(kind) => self.dispatch(AppCommand::ListVersions(kind)), - None => self.dispatch(AppCommand::RefreshAll), - }, - _ => eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"), - }, + pages::Message::Action(action) => { + eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); + } + // 下载页四支 +「刷新下载源」已由上面的 `on_download` 消化,这里只是穷尽性兜底 + _ => {} } } diff --git a/crates/app-iced/src/app/update/download.rs b/crates/app-iced/src/app/update/download.rs new file mode 100644 index 0000000..85ea7d7 --- /dev/null +++ b/crates/app-iced/src/app/update/download.rs @@ -0,0 +1,57 @@ +//! 下载页在 app 层的那几支(M94)。 +//! +//! 为什么单独一个文件:M93(外观三件套接线)合入后 `update.rs` 已经贴到 400 行,下载页再往里塞 +//! 四个 arm 就顶破硬线;拆出来是既不动 M93 的代码、又能守住 ≤400 的办法(`app/update/**` 属本 +//! mission scope)。 +//! +//! ## 四支各自的纪律 +//! +//! | 消息 | 落点 | 为什么 | +//! |---|---|---| +//! | [`pages::Message::SelectVersion`] | **只改视图模型** | 选中是页面内状态,不是业务命令 | +//! | [`pages::Message::InstallVersion`] | `AppCommand::InstallVersion` | 真指令,回事件后才画进度 | +//! | [`pages::Message::CancelInstall`] | `AppCommand::CancelInstall` | 同上(host 侧对没在跑的任务是 no-op) | +//! | [`pages::Message::SetCategory`] | 写分类 → 重建 → `AppCommand::ListVersions` | 见下 | +//! +//! [「刷新下载源」](pages::Action::RefreshSources) 也在这里:它不是占位动作,具体分类 → 重拉该分类、 +//! 「全部」→ 让 host 重扫一遍(`RefreshAll`)。 +//! +//! ## 分类为什么既写视图模型、又派指令 +//! +//! 真 host 的 `AppCommand::ListVersions(_)` 会丢掉分类参数、回全量清单(复审 P2-1),所以**过滤在 +//! 本页做**:写进视图模型 → 立刻 `refresh_view()`(它会把 category 带回去、按同一谓词过滤行)→ +//! 再派指令。这不是「乐观回显」:写的是**过滤条件本身**,chip 与列表用同一个 +//! [`Category::matches`](crate::pages::Category),于是不可能出现「chip 说正式版、列表是全量」的两份真相。 + +use super::*; + +impl App { + /// 下载页的消息。**处理了返回 `true`**(`on_page` 里就不用再写一遍这些 arm)。 + pub(super) fn on_download(&mut self, message: &pages::Message) -> bool { + match message { + pages::Message::SelectVersion(id) => { + self.view.download.selected = Some(id.clone()); + } + pages::Message::InstallVersion(id) => { + self.dispatch(AppCommand::InstallVersion(id.clone())); + } + pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall), + pages::Message::SetCategory(category) => { + self.view.download.category = *category; + self.view_dirty = true; + self.refresh_view(); + if let Some(kind) = category.command() { + self.dispatch(AppCommand::ListVersions(kind)); + } + } + pages::Message::Action(pages::Action::RefreshSources) => { + match self.view.download.category.command() { + Some(kind) => self.dispatch(AppCommand::ListVersions(kind)), + None => self.dispatch(AppCommand::RefreshAll), + } + } + _ => return false, + } + true + } +}