Files
Unlucky-Launcher/crates/app-iced/src/pages/content/view.rs
T
AskaEth 03741ecd2d fix(m161): 收更新键/按钮/矮窗口/启动错误/导入结果五笔债
① update.auto_check / update.channel 登记进 core setting schema(1.6.0)
   - 新增 setting/keys/update.rs(两键 + UPDATE_CHANNELS,只允许 stable);
     SettingSchema 加 with_all 供域键表批量登记。
   - 新增 setting/update_prefs.rs:cache/update/prefs.json → launcher.json 的一次性迁移。
     四条硬约束:源指纹闸门(幂等)、不丢 false、新值胜出、旧文件改名 .migrated 后不再被读;
     主配置不可写时不动旧文件。收据 config/update-prefs-migration.json。
   - presenter Runtime 建会话时迁一次并把结局留在 migration();prefs 字段换成
     auto_check + 每次派发从主配置读 channel(不再硬编码 Channel::Stable)。
     保留旧字段 auto_check 的真实来源(原有测试 cases 会断言)。
   - CLI config get/set/dump 先跑同一次迁移(幂等),输出里给 migration 段与收据路径。

② presenter/dto/events.rs 的「本次会话不再弹提示条」注释改成真实语义
   (记的是版本号:同版本压住、更高版本照弹、手动检查清掉)。

③ 检查/下载进行中按钮禁用:pages/update/busy.rs 的 busy/can_check/can_apply;
   设置页两颗按钮改画禁用(不是消失),app 层 on_update_action 再挡一道
   (忙碌时 CheckNow/Apply 不派发);新增两条用例(含「忙完恢回可点」)。

④ 内容库低高度:content_layout::MIN_BODY_HEIGHT=460 + BodyMode(按固定件+三整行算出来,
   用例守着算式);frame::body_height 从 Ui::metrics 算正文可用高,pages::Model 带 window_height。
   ≥460 列表自己滚;<460 主列整列一个滚动条、列表按自然高度铺开。

⑤ 启动错误文案本地化:presenter 删掉 launch_summary / 「启动失败:」前缀 / 五条中文阶段前缀,
   改发 LaunchFailureView{stage,detail};LaunchFinished 改带 exit_code/uptime_ms/failure,
   Toast 由 app 层按 i18n key 现求(与 install_cancelled 同构)。InstancesFailed 拆成
   InstancesFailedStage(列表单行)+ LaunchStageFailed(详情/Toast 全文)。

⑥ 导入结果块:ImportReportView 加 failure_stage(NotStarted/RolledBack/RollbackIncomplete,
   由 core 的 rollback 机器位推出);标题、图标、底色三处分档,预检失败不再说「已回滚」。

顺带:六处注释/文档漂移(update.rs 的分工表与偏好落点、pack 的「已整包回滚」、
build_request 的 Errors、settings/update 的按钮禁用说明等),
以及为守 400 行把 update/cli-config/core-keys/presenter-dto/presenter-launch/i18n-golden
里贴线或越线的文件按模块拆开(纯搬移,行为不变)。
2026-11-07 15:02:13 +08:00

266 lines
11 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.
//! 内容库页的整页装配:页头(含一级切换)+ 左侧类型栏 + 主列(筛选/结果)+ 详情 + 安装面板。
//!
//! ## 版面(几何在 [`super::layout`],这里只消费结果)
//!
//! ```text
//! ┌─ 页头:Content [版本管理 | 内容库] ─┐
//! ├──────┬────────────────────────┬──────────────┤
//! │ 类型 │ 搜索 + 四组 chip │ 详情 / 装入 │
//! │ 四条 │ 计数头 │ 原因逐条列出 │
//! │ 侧栏 │ 结果列表(独立滚动) │ │
//! │ │ 加载更多 / 没有更多了 │ │
//! ├──────┴────────────────────────┴──────────────┤
//! │ 安装面板(**下载页那块**,只在有安装时出现) │
//! └──────────────────────────────────────────────┘
//! ```
//!
//! ## 为什么类型栏用 `list_item`(与 M107 的版本类型栏同一套)
//!
//! 四条内容类型是**一级侧栏**(页面内导航),几何与状态层必须与版本管理页一致:`list_item`
//! 自带 60 高两行档与选中态,页面不自己写 hover/pressed。窄档(`Tight`)退成一行 chip——
//! 栏宽没了就不该硬塞一条 140px 的栏。
use iced::widget::{column, container, row};
use iced::{Element, Length};
use unlucky_design::spacing::Space;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::ContentKind;
use crate::app::components::divider;
use crate::app::components::navigation::list_item::{list_item, ListItem};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use crate::pages::download;
use crate::pages::frame::{self, Header};
use crate::pages::layout::FrameMetrics;
use crate::pages::Message as PageMessage;
use super::layout::{self as content_layout, Rails, Tier};
use super::model::{Content, Status};
use super::parts;
use super::Message as ContentMessage;
/// 整页入口(由 `pages/mod.rs` 的 `render` 调;页面层不认识 presenter 类型)。
pub(super) fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Content,
lang: unlucky_i18n::Lang,
window_height: f32,
) -> Element<'a, PageMessage> {
let catalog = lang.catalog();
// 正文可用高 → 版面两档(`Split`:列表自己滚;`ScrollWhole`:主列整列一个滚动条)。
// 门槛与判据在 [`content_layout`] 里,那里有用例钉住「不算拍脑袋的数」。
let body_height = frame::body_height(ui, metrics, window_height, true);
let mode = content_layout::BodyMode::of(body_height);
let header = Header::new(catalog.text(Key::ContentTitle))
.subtitle(catalog.text(Key::ContentSubtitle))
.action(parts::section_switcher(ui, catalog, true));
frame::page(ui, metrics, header, body(ui, metrics, catalog, model, mode))
}
fn body<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
catalog: Catalog,
model: &'a Content,
mode: content_layout::BodyMode,
) -> Element<'a, PageMessage> {
let rails = content_layout::resolve(metrics.available);
let mut columns = row![kind_rail(ui, catalog, model, rails)]
.spacing(rails.gap)
.width(Length::Fill)
.height(Length::Fill);
if rails.tier == Tier::Tight {
// 窄到放不下侧栏:类型条跑到主列上方(同一个 `kind_rail`,chip 形态)
let main = column![
kind_rail(ui, catalog, model, rails),
divider::full(ui),
main_column(ui, catalog, model, rails, mode)
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.height(Length::Fill);
columns = row![main].width(Length::Fill).height(Length::Fill);
} else {
columns = columns.push(main_column(ui, catalog, model, rails, mode));
if rails.tier.has_detail() {
columns = columns.push(detail_column(ui, catalog, model, rails));
}
}
let mut stack = column![columns]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill);
if let Some(panel) = &model.panel {
// **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。
stack = stack
.push(divider::full(ui))
.push(download::install_panel(ui, catalog, panel));
}
stack.into()
}
/// 左侧四条内容类型(`Wide`/`Compact` 是列表项,`Narrow` 是图标项,`Tight` 是一行 chip)。
fn kind_rail<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
) -> Element<'a, PageMessage> {
if rails.tier == Tier::Tight {
let mut chips = Vec::new();
for kind in ContentKind::ALL {
chips.push(chip(
ui,
Chip::filter(
kind_label(kind, catalog),
model.kind == kind,
PageMessage::Content(ContentMessage::SetKind(kind)),
),
));
}
return row(chips)
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into();
}
let labels = rails.tier.rail_has_labels();
let mut items = column(Vec::new())
.spacing(ui.space(Space::X1))
.width(Length::Fill);
for kind in ContentKind::ALL {
let label = kind_label(kind, catalog);
// 图标档没有文字位:标签仍传(无障碍/日志用),宽度由几何裁掉
let mut item = ListItem::new(
if labels { label } else { " " },
PageMessage::Content(ContentMessage::SetKind(kind)),
)
.leading(parts::kind_icon(kind));
if model.kind == kind {
item = item.selected();
}
items = items.push(list_item(ui, item));
}
container(items)
.width(Length::Fixed(rails.rail))
.height(Length::Fill)
.into()
}
/// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。
///
/// `mode` 决定**谁滚**(M161,判据在 `content_layout::BodyMode`):
///
/// * `Split`:列表自己滚(筛选条常驻,高窗口的常态);
/// * `ScrollWhole`:矮窗口——主列(含搜索条与筛选)整列进**一个**纵向滚动区,列表按自然高度铺开。
/// 这样一行 `ListItem` 不会被压成半行(那看起来就是「折行挤压」),也不会出现「列表里一个滚动条、
/// 外层又一个」的双滚动。
fn main_column<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
mode: content_layout::BodyMode,
) -> Element<'a, PageMessage> {
// 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排
// 溢出 32px(详情列被切掉一半,复审截图上正是这条)
let mut head = column![parts::search(ui, catalog, model)]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 筛选条**永远在**:它是控制面,不是数据。加载中把筛选条藏起来,用户就没法在慢网络下
// 换条件或换类型(只能干等),这是「加载态顺手把入口收走」的经典坑。
head = head.push(parts::filters(ui, catalog, model));
head = head.push(divider::full(ui));
if model.status == Status::Ready {
// 计数头只在有数据时出现:0 条的「0 results」和空态说的是同一件事,画两遍是噪音
head = head.push(parts::count_head(ui, catalog, model));
}
let body: Element<'a, PageMessage> = if let Some(state) = parts::status_body(ui, catalog, model)
{
state
} else {
// **必须给间距**(复审 P2-1):这一列原先漏了 `.spacing(..)`,而列表自身的纵向内边距是 0,
// 于是「列表视口底边」与「加载更多」贴在一起,末行正好被切在半行上。
let mut list = column![parts::list(ui, catalog, model, rails, mode)]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 列表高度:`Split` 吃掉剩余高度(自己滚);`ScrollWhole` 按内容铺开(外层滚)。
list = if mode.list_scrolls() {
list.height(Length::Fill)
} else {
list.height(Length::Shrink)
};
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
if !rails.tier.has_detail() {
list = list
.push(divider::full(ui))
.push(parts::detail(ui, catalog, model));
}
list.push(parts::more(ui, catalog, model)).into()
};
// `ScrollWhole` 档:主列整列一个滚动区(列表按自然高度)。**只有这一处**加滚动条,
// 不在列表里再套一个(双滚动是「到处加滚动条糊」的典型症状)。
let mut column = column![]
.spacing(ui.space(Space::X2))
.width(Length::Fixed(rails.list));
// M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。
// 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。
if model.kind == ContentKind::Modpack {
column = column
.push(super::packui::section(ui, catalog, &model.pack))
.push(divider::full(ui));
}
column = column.push(head).push(body);
if mode.list_scrolls() {
return column.height(Length::Fill).into();
}
iced::widget::scrollable(column.height(Length::Shrink))
.direction(iced::widget::scrollable::Direction::Vertical(
iced::widget::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()
}
/// 详情列(只在 `Wide` / `Narrow` 存在)。
fn detail_column<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
) -> Element<'a, PageMessage> {
container(parts::detail(ui, catalog, model))
.width(Length::Fixed(rails.detail))
.height(Length::Fill)
.style(move |_theme| container::Style::default())
.into()
}
/// 四种内容类型的界面名。
fn kind_label(kind: ContentKind, catalog: Catalog) -> &'static str {
match kind {
ContentKind::Modpack => catalog.text(Key::ContentKindModpack),
ContentKind::Mod => catalog.text(Key::ContentKindMod),
ContentKind::Resourcepack => catalog.text(Key::ContentKindResourcepack),
ContentKind::Shader => catalog.text(Key::ContentKindShader),
}
}