Files
Unlucky-Launcher/crates/app-iced/src/pages/instances/detail.rs
T
AskaEth f03411d58b fix(instances): M302 r1 P2 —— 冷启动首帧三态(占位 / 真空态 / 有实例)+ N1~N4
复审 P2-1:扫盘搬后台之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、
不 `tick`),**首帧必然**在快照之前。改前那一帧画的是「计数 0 + 还没有实例 + 实心导入 CTA」
——一句假话,而且 CTA 能点。改后三态分开:

- 真源放 presenter:`dto::InstancePhase { Scanning, Stalled, Ready }`,`AppState::instances_phase`
  在 `CoreEvent::InstancesChanged` 落地时置 `Ready`;**不从 `instances.is_empty()` 推**(那就是 bug 形态)。
- `Scanning` = 首个快照未到:详情列画「正在扫描实例…」占位(新 key `InstancesScanning`,golden 两语),
  列头**不写计数**(藏起来,不写 0 也不写「—」),**没有**空态 CTA。
- `Stalled` = 有界兜底:`host::game::SCAN_STALL = 15 s`(= 仓库内最坏实测 1.35 s 的约 11 倍,
  理由在常量文档里)。超时只**报**一次、不掐线程;迟到的结果照样落地(代次没变)。
  那一屏给一枚「重新扫描」→ `InstanceMessage::RetryScan` → 新命令 `AppCommand::RefreshInstances`
  (只重扫实例目录,不重拉版本清单)。那一支在 `selected_instance_id()` 守卫**之前**——冷启动没有选中项。
- `Ready` = 收到过快照(空列表也算):这时才轮到 M273 的两种空态(没选中 / 一个都没有)。
- 已就绪时**不发** `Scanning`:否则每次刷新列表都退回占位(把卡顿换成闪屏)。

判据(都能红,变异原文见报告):
- `pages/instances/tests/cold_boot.rs`:真控件树(未就绪 7 枚按钮 = 有实例那一屏,零实例 = 8 枚,
  多的那枚就是 CTA;每行 2 条文字节点的差分数行数)+ 源码结构(`Scanning`/`Stalled` 两支不碰
  `detail_empty` 与空态文案、超时那支接 `RetryScan`、三个 key 真的被读)。
- `app/update/instances/scan_phase.rs`:首帧 = `Scanning`、空快照 = `Ready`、`Stalled` 经重试回到
  `Scanning`、重试只派 `RefreshInstances`。夹具 `ULL_INSTANCE_PHASE` 只给出图(与 `ULL_SAVES_FIXTURE` 同一条纪律)。

顺手收 N1~N4(全是措辞 / 观测口径,不动行为):
- N1 两处 docstring 不再声称「`admit` 写成 `>=` 会红」(实测等价),改成「去掉或恒真才红」;
- N2 收尾用例那条 Idle 断言的失败消息改准(它不区分新旧快照,区分靠条数/代次断言);
- N3 boot 双刷新必然丢 1 份 ⇒ 丢弃日志分成「此前没有快照落地(启动期,非交错)」与「已有落地(真交错)」,
  `Scan::dropped` 的文档登记这条已知噪音;
- N4 app 用例 docstring 收窄成「一拍无事件不清表、不钉时钟」(它绕过 `App::dispatch`,抓不住那 5 行)。

拆文件守 400 行:`detail/scan.rs`(分派 + 占位)、`refresh_stall_tests.rs`(空根 / 超时)、
`app/update/instances/scan_phase.rs`(app 级三态用例);`i18n` 的 `PAGE_SOURCES` 同步加上新页面文件。
2026-11-13 19:00:30 +08:00

372 lines
14 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.
//! 实例页的**详情列**(M90 r2 从 `instances.rs` 拆出来:那边超过 400 行会被 guards 的 file-size 判红)。
//!
//! 结构:标题行(名字 + 版本 + Play/Stop)→ 元信息网格(列数由 `layout::meta_columns` 决定)
//! → 一条分隔线 → 正文段 / 运行状态行 → 动作行(Duplicate / Open folder / Delete)。
//! 窄窗(<840)时列表退成上一屏,本文件只画详情 + 一个「返回」按钮。
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_presenter::router::Route;
use crate::app::components::buttons::{self, Button};
use crate::app::components::divider;
use crate::app::components::interaction::Interaction;
use crate::app::components::surfaces::elevated;
use crate::theme::Ui;
use unlucky_design::icons::IconKind;
use super::super::frame;
use super::super::layout::{self, FrameMetrics};
use super::super::model::Detail;
use super::super::{Action, Message};
use super::forced_state;
use super::state::LaunchForm;
use super::Texts;
mod scan;
pub(in crate::pages) use scan::detail_side;
// --------------------------------------------------------------- 详情列
pub(super) fn detail_column<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
detail: &'a Detail,
form: &'a LaunchForm,
mods: &'a super::Mods,
saves: &'a super::Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let mut body = column![
detail_head(ui, detail, forced, texts),
meta_grid(ui, metrics, detail),
divider::full(ui),
]
.spacing(ui.space(Space::X4))
.width(Length::Fill);
if let Some(note) = discovered_note(ui, detail, texts) {
body = body.push(note);
}
body = push_text(ui, metrics, body, detail);
// M224:MODS 区排在启动参数**之前**——详情列是一个滚动区,而启动参数卡本身就有一屏高,
// 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。
// 顺序因此是:身份(页头 + 元信息)→ 内容(MODS)→ 选项(启动参数)→ 动作。
body = body.push(super::mods::section(ui, mods, forced, texts));
// M235:SAVES 区紧跟 MODS 区(同为"内容"),理由与 MODS 一样:排在启动参数卡后面会落到
// 折叠线以下——详情列是一个滚动区,而启动参数卡本身就有一屏高。
body = body.push(super::saves::section(ui, saves, forced, texts));
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)。
//
// M157 r2(D30 复审):详情列的内容(正文段 + 启动参数卡 + 动作行)在 1180×1000 下**超出一屏**,
// 而这里原来没有滚动区 ⇒ Max memory 以下的控件被静默裁掉、够不着。现在整列套一个滚动区
// (与左列列表同一套滚动条样式与宽度),被裁内容可达;滚动条是唯一入口,没有第二份状态。
let content = body.push(action_row(ui, forced, texts));
frame::left_aligned(metrics.column, scroller(ui, content))
}
pub(super) fn narrow_detail<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
detail: &'a Detail,
form: &'a LaunchForm,
mods: &'a super::Mods,
saves: &'a super::Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
// 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。
let mut body = column![
row![buttons::text(
ui,
forced_state(
Button::new(texts.back(), Message::Navigate(Route::Instances)),
forced,
),
)]
.width(Length::Fill)
.align_y(Alignment::Center),
detail_head(ui, detail, forced, texts),
meta_grid(ui, metrics, detail),
divider::full(ui),
]
.spacing(ui.space(Space::X4))
.width(Length::Fill);
if let Some(note) = discovered_note(ui, detail, texts) {
body = body.push(note);
}
body = push_text(ui, metrics, body, detail);
// 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。
body = body.push(super::mods::section(ui, mods, forced, texts));
body = body.push(super::saves::section(ui, saves, forced, texts));
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
texts.narrow_note(),
ui.color(Role::OnSurfaceVariant),
));
frame::body(
metrics,
scroller(ui, body.push(action_row(ui, forced, texts))),
)
}
/// 详情列的滚动区(M157 r2):与左列列表同一套滚动条几何(宽 6 / 内缩 2 / 同一 `style`)。
///
/// 右侧留 `Space::X2` 内缩:滚动条是**覆盖**在内容上的,不留这条缝时卡片的 1dp 描边会被
/// 滚动条压住(D30 复审里「卡片右边框缺一截」就是它)。
fn scroller<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let content = container(content)
.width(Length::Fill)
.padding(Padding::default().right(ui.space(Space::X2)));
scrollable(content)
.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()
}
/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。
///
/// 散文段套 [`frame::prose`]:M102 起页面块吃满页宽,但「一行 ≤72 字符」的承诺仍然只由
/// 散文承担(`frame::body_width` 管块、`metrics.column` 管一句话)。
fn push_text<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
mut body: iced::widget::Column<'a, Message>,
detail: &'a Detail,
) -> iced::widget::Column<'a, Message> {
if let Some(note) = &detail.note {
body = body.push(frame::prose(
metrics,
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
note.as_str(),
ui.color(Role::OnSurfaceVariant),
),
));
}
if let Some(status) = &detail.status {
body = body.push(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
status.as_str(),
ui.color(Role::OnSurface),
));
}
body
}
fn detail_head<'a>(
ui: &'a Ui,
detail: &'a Detail,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let titles = column![
frame::text_role(
ui,
TypeRole::TitleLarge,
true,
detail.name.as_str(),
ui.color(Role::OnSurface)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
detail.subtitle.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
// 主操作:**全屏唯一**实心 accent(rubric K2)。不可用时退化成禁用态(只清消息,不改身份)。
//
// M237:这一行**不在卡片里**(详情列直接铺在画布上),所以这两个按钮的底保持
// `Base::CANVAS`——把卡底传进来反而会让禁用色叠错层。卡片内的控件(MODS / SAVES /
// 启动参数区)各自传 `frame::BASE`,见那几个模块。
let launch = if detail.can_launch {
buttons::filled(
ui,
forced_state(
Button::new(texts.play(), Message::Launch(detail.id.clone())),
forced,
),
)
} else {
buttons::filled(ui, Button::disabled(texts.play()))
};
let stop = if detail.can_stop {
buttons::text(
ui,
forced_state(
Button::new(texts.stop(), Message::Stop(detail.id.clone())),
forced,
),
)
} else {
buttons::text(ui, Button::disabled(texts.stop()))
};
row![titles, launch, stop]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 自动发现的实例多一句话:**运行目录由盘上已有的文件决定**(M273 r1)。
///
/// 具体的档位就在上面的 ISOLATION 那一格(共用目录 / 独立目录),这里补的是「为什么」——
/// 用户看到「共用目录」时必须能回答「我明明在 HMCL 里给它装过 mod」。
/// 判据只有 id 前缀([`unlucky_presenter::dto::is_discovered_instance`]),界面层不自己判盘。
fn discovered_note<'a>(ui: &'a Ui, detail: &Detail, texts: Texts) -> Option<Element<'a, Message>> {
unlucky_presenter::dto::is_discovered_instance(&detail.id).then(|| {
frame::text_role(
ui,
TypeRole::BodySmall,
false,
texts.discovered_note(),
ui.color(Role::OnSurfaceVariant),
)
})
}
/// 元信息网格:列数由 [`layout::meta_columns`](按正文列宽)决定 —— **1180 与 900 都是 2 列**。
///
/// 3 列需要 `3 × META_CARD_MIN_WIDTH + 2 × META_GAP` = 504px,而行宽上限是 424,**数学上不可行**;
/// 硬塞回 3 列会让 `NeoForge 20.4.237`(118px)这类值按词折行、同排卡底不齐。
fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail) -> Element<'a, Message> {
let mut grid = column![].spacing(layout::META_GAP).width(Length::Fill);
let columns = metrics.meta_columns.clamp(1, detail.meta.len().max(1));
for chunk in detail.meta.chunks(columns) {
let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill);
for cell in chunk {
// M229:元信息卡 = 规格卡的 **ELEVATED CARD**(无描边、`SurfaceContainer` 底、16 圆角)。
// 内距 20 + 值 **12/400**(见 `elevated::PAD` / `elevated::TITLE_ROLE` 的登记):
// 外框 160 − 2×20 = 120 必须放下
// 最长值串,24 + 16 会让时戳折行、同排卡底不齐(复审 P1-2)。
let card = elevated::card(
ui,
elevated::texts(
ui,
elevated::Texts {
overline: Some(cell.label.as_str()),
title: cell.value.as_str(),
support: None,
},
),
);
cells_row = cells_row.push(container(card).width(Length::Fill));
}
grid = grid.push(cells_row);
}
grid.into()
}
fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>, texts: Texts) -> Element<'a, Message> {
row![
buttons::text(
ui,
forced_state(
Button::new(texts.duplicate(), Message::Action(Action::Duplicate)),
forced,
)
),
buttons::text(
ui,
forced_state(
Button::new(texts.open_folder(), Message::Action(Action::OpenFolder)),
forced,
)
),
buttons::text(
ui,
forced_state(
Button::new(texts.delete(), Message::Action(Action::Delete)),
forced
),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.into()
}
/// 详情列的**空态**,两种含义分得很清楚:
///
/// | `has_instances` | 标题 / 说明 | 主行动 | 画在哪 |
/// |---|---|---|---|
/// | `true`(列表有东西,但没选中) | 「未选择实例」+ 「在左侧选一个」 | **无** | [`empty_not_selected`] |
/// | `false`(一个实例都没有) | 「还没有实例」+ 「导入一个已经装在盘上的版本」 | **导入已有版本**(实心按钮) | [`empty_no_instances`] |
///
/// 为什么要分开(M273):用户现场 `instances=0`,却看到「未选择实例」——那句话在暗示
/// 「你已经有的东西只是没点」,于是他找不到任何入口。一个都没有时必须给一条**看得见**的出路:
/// 接管盘上已经装好的版本。
///
/// 两个分支各自成一个函数,不是为了好看:CTA 落在哪一支、另一支有没有动作,只能这样分开断言。
/// 「传参有没有传反」由调用点那一行的源码断言守(`tests/empty_entry.rs`)——那是本函数看不见的
/// 事实,r1 复审的变异(把 `!block.rows.is_empty()` 写成 `block.rows.is_empty()`)就是从这里溜的。
pub(super) fn detail_empty<'a>(
ui: &'a Ui,
texts: Texts,
has_instances: bool,
) -> Element<'a, Message> {
if has_instances {
empty_not_selected(ui, texts)
} else {
empty_no_instances(ui, texts)
}
}
/// 有实例、但没选中:只说明「去左边点一个」,**不给动作**(动作在左列,点了就有)。
fn empty_not_selected<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
frame::empty_state(
ui,
IconKind::Instances,
texts.empty_title(),
texts.empty_body(),
Vec::new(),
)
}
/// 一个实例都没有:给一条主行动(导入已有版本 = 打开已有的接管向导)。
fn empty_no_instances<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
frame::empty_state(
ui,
IconKind::Instances,
texts.empty_none_title(),
texts.empty_none_body(),
vec![buttons::filled(
ui,
Button::new(texts.adopt_cta(), super::adopt_action()),
)],
)
}