Files
Unlucky-Launcher/crates/app-iced/src/pages/content/layout.rs
T
AskaEth 6b161227d8 feat(app-iced): M109 CONTENTDL —— 内容库页(整合包/Mod/资源包/光影)
用户令「下载页只负责下载整合包和Mod/光影」:新开一页做**内容发现与装入**,
检索走 Modrinth(优先,无需 key)/ CurseForge(`ULL_CF_API_KEY` 环境变量,日志只打
set/unset),装入走 core 的下载引擎真下到实例目录,进度/取消/失败复用下载页那块安装面板。

- **入口(B1)**:`Route::Content` 是新顶层路由,但 `Route::nav()` 复用 `NavSection::Download`
  ——导航轨仍五项,页头右上是「版本管理 | 内容库」一级切换(版本管理页也加了同一个切换,
  否则内容库只有 `ULL_START_ROUTE` 能到)
- **页内四条一级侧栏**(`pages/content/layout.rs` 四档几何:992/856/648 三个门槛都是
  「Σ栏宽 + 间距」算出来的):整合包 / Mod / 资源包 / 光影;标签栏 200(`Resource packs`
  比 M107 的版本类型名长,140 会切字,已登记偏离)
- **筛选**:关键词(草稿 + 显式「搜索」按钮,不逐字打网络)、来源、游戏版本、加载器
  (Fabric/Forge/NeoForge/Quilt)、排序;**分页**(「加载更多」)而不是无限滚动——快照只有一个槽、
  翻页要看得见、状态要可截图复现
- **四态**:加载 / 空 / 失败 / 有数据 + 「没有更多了」;筛选条在任何状态下都在(加载中藏筛选条
  等于把入口收走)
- **一键装入**:`Content::blockers()` 是纯函数——版本不匹配、加载器不匹配、没有目标实例、
  整合包缺管线**四条原因逐条**给出,按钮点之前就禁用;判定与画法分开(model/tests 直接钉)
- **整合包今天不可装**:`ContentKind::Modpack.dir()` 是 `None`,UI 禁用 + 说明,host 侧再挡
  一次(绕过 UI 也只会回一条 Failed)。真流程是 M111 的导入管线
- **零 UI 网络**:`presenter/host/addon.rs` + `addon/{sources,http,install}.rs`;无 header 的请求走
  core 的 `download_bytes`(信号量限流 + 健康度 + 退避全现成),带 `X-API-KEY` 的只有 CF 一条,
  自带 429/5xx 退避(优先吃 `Retry-After`,封顶 30s);密钥不进 DTO/日志/仓库
- 文案 41 条中英进 `crates/i18n`(新域接在装配链末尾 ⇒ `Key::ALL` 与金表只追加)
- 演示数据 `ContentHit → Hit::of` 与产品路径同源(格式化不会两套),`ULL_CONTENT_KIND` /
  `ULL_CONTENT_STATE` 供出图矩阵选类型与状态

取证:`DevDocs/artifacts/m109/`(10 张:四类内容 × 浅/深 + 搜索无结果 + 加载器不兼容禁用)

门禁:fmt ✓ / clippy `-D warnings`(三 crate)✓ / app-iced 450+6、presenter 170、i18n 24 全绿;
`cargo tree` 两条零 UI 门禁仍空;全部改动文件 ≤400 行。
2026-11-05 03:20:21 +08:00

195 lines
6.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 内容库页的栏几何(纯函数 + 数值表)。
//!
//! ## 为什么这一页自带一份几何
//!
//! 任务书要求「与 M107 同一套视觉语言」:类型栏 140 / 图标栏 64、间距 24、列表列下限 424
//! (行宽红线)、详情 320。M107 的那份几何在 `pages/layout/rails.rs`(M107 分支,尚未并入 main),
//! 本 mission 的分支基于 main,所以这里按**同一组数值**先落一份,并在报告里登记
//! 「M107 合并后应改为复用 `pages/layout/rails.rs`,删掉本文件」。
//!
//! ## 四档的判据是**算出来的**,不是拍的
//!
//! | 档 | 条件(可用宽,已扣导航轨与页边距) | 版面 |
//! |---|---|---|
//! | `Wide` | ≥ 140 + 24 + 424 + 24 + 320 = **932** | 标签栏 + 列表 + 详情 |
//! | `Narrow` | ≥ 64 + 24 + 424 + 24 + 320 = **856**(类型栏收成图标栏) | 图标栏 + 列表 + 详情 |
//! | `Compact` | ≥ 140 + 24 + 424 = **588** | 标签栏 + 单列(详情整页替换列表) |
//! | `Tight` | 其余 | 类型条变成顶部一行 chip + 单列 |
//!
//! 三个门槛都是「Σ栏宽 + 间距」算出来的,改动任何一档都会让数值表用例红。
use unlucky_design::spacing::Space;
use crate::pages::layout;
/// 类型栏宽(标签形态)。
///
/// M107 的版本类型栏是 140;这里取 200:四个**内容类型**的名字(`Resource packs`)比
/// 版本类型(`Release` / `Snapshot`)长,140 会把标签切掉。登记在 `pages/content.rs` 的偏离表。
pub const RAIL_LABEL: f32 = 200.0;
/// 类型栏宽(图标形态)。
pub const RAIL_ICON: f32 = 64.0;
/// 详情面板宽。
pub const DETAIL: f32 = 320.0;
/// 栏间距(`Space::X6` = 24,与 `pages::layout::PANES_GAP` 同源)。
pub const GAP: f32 = Space::X6.dp();
/// 一屏三栏各自多宽。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rails {
/// 档位
pub tier: Tier,
/// 类型栏宽(`Tight` 档是 0:类型条在顶部)
pub rail: f32,
/// 列表列宽
pub list: f32,
/// 详情面板宽(0 = 这一档没有独立详情列)
pub detail: f32,
/// 栏间距
pub gap: f32,
}
/// 版面档位。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier {
/// 标签栏 + 列表 + 详情
Wide,
/// 图标栏 + 列表 + 详情
Narrow,
/// 标签栏 + 单列
Compact,
/// 顶部 chip 条 + 单列
Tight,
}
impl Tier {
/// 这一档有独立详情列吗。
pub const fn has_detail(self) -> bool {
matches!(self, Self::Wide | Self::Narrow)
}
/// 这一档的类型栏是标签形态吗(`Tight` 没有栏)。
pub const fn rail_has_labels(self) -> bool {
matches!(self, Self::Wide | Self::Compact)
}
}
/// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。
pub fn wide_min() -> f32 {
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
}
/// `Narrow` 的门槛:图标栏 + 间距 + 列表下限 + 间距 + 详情。
pub fn narrow_min() -> f32 {
RAIL_ICON + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
}
/// `Compact` 的门槛:标签栏 + 间距 + 列表下限。
pub fn compact_min() -> f32 {
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX
}
/// 可用宽 → 三栏几何。
pub fn resolve(available: f32) -> Rails {
let cap = layout::measure_cap_px();
if available >= wide_min() {
let rail = RAIL_LABEL;
return Rails {
tier: Tier::Wide,
rail,
gap: GAP,
detail: DETAIL,
list: (available - rail - GAP - DETAIL - GAP).max(layout::LINE_SAFE_WIDTH_PX),
};
}
if available >= narrow_min() {
let rail = RAIL_ICON;
return Rails {
tier: Tier::Narrow,
rail,
gap: GAP,
detail: DETAIL,
list: (available - rail - GAP - DETAIL - GAP).max(layout::LINE_SAFE_WIDTH_PX),
};
}
if available >= compact_min() {
let rail = RAIL_LABEL;
return Rails {
tier: Tier::Compact,
rail,
gap: GAP,
detail: 0.0,
list: (available - rail - GAP).min(cap.max(layout::LINE_SAFE_WIDTH_PX)),
};
}
Rails {
tier: Tier::Tight,
rail: 0.0,
gap: GAP,
detail: 0.0,
list: available.min(cap.max(layout::LINE_SAFE_WIDTH_PX)),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 门槛必须由「Σ栏宽 + 间距」算出来(抓得住:谁把 932 抄成 900 这种拍值)。
#[test]
fn the_three_thresholds_are_sums_not_magic_numbers() {
assert_eq!(wide_min(), 200.0 + 24.0 + 424.0 + 24.0 + 320.0);
assert_eq!(narrow_min(), 64.0 + 24.0 + 424.0 + 24.0 + 320.0);
assert_eq!(compact_min(), 200.0 + 24.0 + 424.0);
assert_eq!(wide_min(), 992.0);
assert_eq!(narrow_min(), 856.0);
assert_eq!(compact_min(), 648.0);
}
#[test]
fn each_tier_takes_effect_at_its_own_threshold() {
assert_eq!(resolve(wide_min()).tier, Tier::Wide);
assert_eq!(resolve(wide_min() - 0.5).tier, Tier::Narrow);
assert_eq!(resolve(narrow_min()).tier, Tier::Narrow);
assert_eq!(resolve(narrow_min() - 0.5).tier, Tier::Compact);
assert_eq!(resolve(compact_min()).tier, Tier::Compact);
assert_eq!(resolve(compact_min() - 0.5).tier, Tier::Tight);
}
#[test]
fn a_wide_page_never_gives_the_list_less_than_the_row_width_red_line() {
// 1180 窗宽(右栗收起):1044 可用 → Wide,列表 536
let rails = resolve(1044.0);
assert_eq!(rails.tier, Tier::Wide);
assert!(rails.list >= layout::LINE_SAFE_WIDTH_PX, "{}", rails.list);
// 逐档扫一遍:只要还有独立列表列,就不许低于 424(行宽红线)
for available in 500..=1600 {
let rails = resolve(available as f32);
if rails.rail > 0.0 {
assert!(
rails.list >= layout::LINE_SAFE_WIDTH_PX,
"available={available} 时列表只剩 {}",
rails.list
);
}
}
}
#[test]
fn the_detail_column_only_exists_in_the_two_wide_tiers() {
assert!(Tier::Wide.has_detail());
assert!(Tier::Narrow.has_detail());
assert!(!Tier::Compact.has_detail());
assert!(!Tier::Tight.has_detail());
assert_eq!(resolve(1200.0).detail, DETAIL);
assert_eq!(resolve(700.0).detail, 0.0);
// 图标形态只在这两档出现,且窄档的标签栏不存在
assert_eq!(resolve(900.0).rail, RAIL_ICON);
assert_eq!(resolve(700.0).rail, RAIL_LABEL);
assert_eq!(resolve(400.0).rail, 0.0);
assert!(Tier::Wide.rail_has_labels());
assert!(!Tier::Narrow.rail_has_labels());
}
}