Files
Unlucky-Launcher/crates/app-iced/src/pages/mod.rs
T
AskaEth c5009442c5 fix(presenter): 自动发现实例按盘上痕迹选运行目录 + 空态用例补上分支/传参(M273 r1)
r1 复审的两条 P2。

## P2-1:外部实例不再一律 isolated

`versions/<id>/` 里出现 `mods` / `saves` / `config` / `options.txt` 任一 ⇒ `isolated`
(gameDir = `versions/<id>`);否则 ⇒ `shared`(gameDir = 游戏根 `.minecraft`)。
判据只 `exists()`,只读,不改盘;写盘仍然只在用户第一次改设置时(`ensure_profile`)。

- 判据收在 `host/game/discovered.rs::base_mode()`,签名 `(env, id, version_id)`:
  **有档案的实例返回全局 `instances.mode`**(D20 原口径,一字不变),自动发现的按痕迹判。
- 派生运行目录的五处(启动 / mods / saves / addon install / addon pack)从传
  `global_mode` 改成传 `base_mode` ⇒ 「读到的存档/模组在哪」与磁贴上的 ISOLATION 同一份判据。
- `isolation_of(env, id, version_id)` 多收一个版本 id:实例文件优先,缺省 `base_mode`。
  于是懒创建档案(只写 launch 段、isolation 仍为空)之后,磁贴不会从「共用目录」翻回「独立目录」。
- 磁贴/详情:自动发现的实例多一句「运行目录由盘上已有的文件决定」(i18n 中英);
  按启动时再弹一条说清**这一次**用哪一边(`pages::instances::discovered_run_toast`,
  同文案在屏不重复叠加)。
- `dto::DISCOVERED_ID_PREFIX` / `is_discovered_instance` 成为「自动发现」的公开判据
  (不给 `InstanceSummary` 加字段:那要改 24 处结构体字面量,跨 agent / cli)。

## P2-2:空态用例的覆盖面

r1 复审实测:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`(0 实例时反而没按钮),
当时三条用例全绿。现在:

- `detail_empty` 拆成 `empty_not_selected` / `empty_no_instances` 两个函数,分支各自可断言;
- `the_cta_lives_only_in_the_no_instances_branch`:CTA 只在「一个都没有」那一支、
  「没选中」那一支必须是 `Vec::new()`,且分派必须写成 `if has_instances {`(取反即红);
- `the_page_asks_the_empty_state_with_the_negated_row_count`:两条调用点(宽窗 + 窄窗)
  都必须传 `!block.rows.is_empty()`,条数也钉住(多一条即红)。
- 两个变异已自证:传参取反 ⇒ 后一条红;CTA 挪支 ⇒ 前一条红(红原文见交付报告)。

## P3

- `java::target_major()` 改读并集快照:只有自动发现实例的用户原来会拿到默认 JDK17 而不是
  该版本要求的 JDK(`profiles.json` 是空的)。有档案时顺序与旧写法一致。
- `instances()` 全量扫仍在 UI 线程——本轮不动,刷新时机与代价写进报告(塔另开一条)。

## 结构

`host/game.rs` 394 → 286 行:自动发现那一簇(痕迹判据 / 扫出来的版本 / 合成视图)搬到
`host/game/discovered.rs`(138 行),只为守 400 行硬线;`game::base_mode` 仍是同一条路径。

## 门禁

app-iced lib 973 passed / presenter all-targets 386 passed / i18n 36 passed /
fmt 0 / clippy -D warnings 0。
2026-11-12 14:21:30 +08:00

398 lines
21 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.
//! # 页面层(P4):布局系统 + 四页 + **路由/真组件的接入口**(M84 `PAGEFRAME` → M90 `APPSKEL`)
//!
//! ## 这一层交了什么
//!
//! | 阶段 | 交付 | 判据 |
//! |---|---|---|
//! | M84 | **布局系统**(档位/版面/列宽)+ 四页占位视觉 | 下面的版面阈值表 |
//! | **M90(本文件当前状态)** | ①视图模型 [`model`] ②导航项接上 `presenter::router::Route` ③**实例页整页接真组件 + 真数据** | 「接真组件后版面约束一条没破」(见 `pages/tests.rs` 的数值断言) |
//! | M99 `SETTINGSFRAME` | **纯搬移**:导航表搬到 [`nav`],设置页拆成目录(`pages/settings/**`),i18n 目录按域拆开 | 截图逐字节相同 + `catalog` 金表(`key -> 译文` 全量不变) |
//!
//! **导航轨归属(M90 r3 订正,复审 P2-1)**:产品路径(`app/view.rs`)走
//! `shell::view_with_content`,导航轨由 **`shell/nav.rs` 的手搓实现**画——它与 M78 组件几何同值
//! (宽 88、药丸 56×32),复审逐像素量过「r1 产品图(真组件 rail)vs r2 产品图(shell rail)
//! 在 y≥48 区域差异为 0」;组件版的 `nav::rail` 现在只服务 `pages::app`(`pages_demo` 出口图、
//! harness)与单测。为什么不是「把 rail 挪进内容槽位」:那需要把 `shell` 的消息类型泛型化/映射,
//! 成本远大于收益(见偏离表 #10)。
//!
//! 实例页之外的三页**仍是 M84 的占位视觉**(下载/日志/设置),但它们已经:
//! 走同一个 `Message` → `update` → `Route` 通道,导航轨点得动、路由栈按 `presenter::router` 的语义走。
//! 页面美化是后续 mission 的事,本 mission 只保证「地基是通的」。
//!
//! ## 版面规矩(M84 立的三条,M90 一条没动)
//!
//! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]);
//! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`];
//! 3. **行宽上限**:正文列硬上限 **424px(逐行实测 67 字符 ≤72)**([`layout::measure_cap_px`]),
//! 由 [`layout::content_column_width`] / [`layout::frame_metrics`] **无条件施加**,
//! 页面拿不到「更宽」的选项。
//!
//! ## 版面阈值表(`layout` 模块文档是全文,这里给速查)
//!
//! | 版面 | 用它的页面 | 成立条件 | 退化 |
//! |---|---|---|---|
//! | `SingleColumn` | 下载 / 日志(永远);实例 / 设置(窄窗) | 总是 | — |
//! | `SupportingPane` | 设置 | `w ≥ 600` 且 `w ≥ 720`(240 + 24 + 320 + 2×24 + 88) | 单列(分组按组堆叠) |
//! | `ListDetail` | 实例 | `w ≥ 840`(官方 expanded)且 `w ≥ 816` | 单列(列表 ↔ 详情整页切换) |
//!
//! ## 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`layout`] | **布局系统**:档位 / 版面 / 列宽 / 阈值,纯函数 + 单测(无 iced 控件树) |
//! | [`model`] | **视图模型**:presenter 快照 → 页面要画的东西(页面层不认识 presenter 类型) |
//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线 |
//! | [`nav`] | 导航轨的 `NAV` 表 + 部件版 `rail()`(harness / 单测;产品走 `shell/nav.rs`) |
//! | [`message`] / [`screen`] | `pages::Message` / `Action` / `Screen`(M101 r2 从本文件原样搬出) |
//! | `instances` | 实例页(list-detail)——**M90 唯一打通到真组件 + 真数据真命令的页** |
//! | `settings` | 设置页(supporting-pane,占位视觉):分组注册表 + 每个分组一个文件 |
//! | `download` / `logs` | 下载 / 日志页(单列,占位视觉) |
//! | [`harness`] | 截图 harness(`examples/pages_demo.rs` 的入口,**不是** `cli.rs` 的一部分) |
//!
//! ## 消息与路由(M90 新增)
//!
//! 页面**不 import `crate::app::Message`**(组件契约 §2 的同一条纪律):它有自己的
//! [`Message`],app 层只做一次 `.map`。路由跳转的载荷是 `presenter::router::Route` 本身,
//! 于是「页面里点一行」= 「压一层栈」,语义与 presenter 的 `Router` 天然一致,
//! 中间不再有一层「页面自定义的选中项」需要同步。
//!
//! ### 设置页只有一个投递口(M101 `SETTINGSMSG`)
//!
//! 设置页的四条填页线各自定义消息与状态,本文件的 [`Message`] 只留一个 [`Message::Settings`]
//! (见 `pages/settings/mod.rs`)。`Message` / `Screen` 在 M101 r2 搬到 [`message`] / [`screen`],
//! 这里的 `pub use` 保证 `pages::Message` / `Action` / `Screen` 三条路径一字未变。
//!
//! ## 偏离登记(D30 要求逐条写清;M84 六条 + M90 三条)
//!
//! | # | 偏离 | 为什么 | 什么时候收口 |
//! |---|---|---|---|
//! | 1 | 页头按 M3 口径**铺满页面宽度**(贴页边距),不与正文列对齐 | AppBar 本来就该满宽;1180 下页头右缘 **1156** 与详情列右缘 **896** 有 **260px 分歧** | 真 top bar 落地时按 M3 定语(页头右侧是操作区,不跟随列宽) |
//! | 2 | 空态标题 `HeadlineSmall 24` 比页标题 `TitleLarge 22` 大 | 12b §15.2 明确「空态是唯一允许 Headline 级的场景」,与 §16.4 的页标题档有 spec 张力 | 有意保留,不修 |
//! | 3 | 单列页(下载 / 日志)在 1180 下只用掉 **40.6%** 内容宽(424/1044) | 行宽上限的必然结果;空态居中不显空 | 版本浏览真正接数据后重新决议 `PaneKit` |
//! | 4 | 空态图标字形 **40px**(12b 15.1 是 `[估]` 32~48) | 令牌 `IconSize` 只有 24 一档,`frame::glyph` 按尺寸重算 stroke | 组件库的 `EmptyState`(M77)落地时 |
//! | 5 | 窄窗「Back to instances」没有前导箭头 | 占位文本按钮没有图标位 | 真 top bar / M78 的导航件落地时 |
//! | 6 | 实例页「未选中详情」空态**没有出图** | 演示数据固定选中第 1 条;空态代码路径在(`detail_empty`) | D30 状态矩阵补齐时补一张,不必重跑全套 |
//! | 7 | ~~标题栏仍是 48px 留白带~~ → **已于 M90 r2 修**:产品页现在走 `shell::view_with_content`,标题栏 / 拖动热区 / 两条缩放热区 / 三个 caption 键全在 | r1 的实质问题是**交互面缺失**(无边框窗口 + 产品页没有任何窗口装饰 ⇒ 拖不动、缩不了、点不到关闭),不是「少画了一条栏」 | ✅ 已收口(见下) |
//! | 8 | 实例页的「New instance / Import」仍是**没有后端的按钮**(M90) | presenter 没有「打开新建向导」的指令(`wizard` 只有状态机,`AppCommand` 里无对应项),已报 finding | 向导 mission 补 `AppCommand::OpenWizard` 之后 |
//! | 10 | **两套 rail 各测各的**(M90 r3):产品走 `shell/nav.rs`,组件版只给 harness/单测 | r2 把产品页接进真外壳后,rail 自然落到 `shell/nav.rs`(像素等价,见上)。但两边的守门是分开的:`shell/nav.rs` 自带 indicator 56×32 的单测,`pages/tests.rs` 守的是组件 rail 的 88 ⇒ **将来哪边先漂没人拦**。统一要泛型化 shell 的消息类型,成本大于收益 | 谁做「rail 单一实现」的 mission(已在报告登记);在那之前任何改 rail 几何的改动都要两处一起改并各自跑守门 |
//! | 9 | 实例页的正文段落只在**演示数据**里出现(M90) | 真快照里没有「正文描述」这种字段;M84 那段 361 字符的 `NOTE` 是给行宽测量当样本的,不能凭空造到真页面里 | i18n / 实例详情 mission 补真实描述字段 |
//!
//! ### 偏离 #7 的收口记录(M90 r2 / P1-1)
//!
//! r1 的产品页渲染的是 `pages::app`:一条**只有高度、没有控件**的留白带,而五种窗口消息的构造点
//! 只有 `shell/mod.rs`、窗口又是 `decorations: false` ⇒ 拖不动、缩不了、点不到关闭。
//! 修法:`shell::view_with_content` 把外壳(标题栏 + 三条热区 + 导航轨)与内容区拆开,产品页把
//! [`page`] 塞进内容槽位;`shell::view`(M68 骨架卡)留给 `Route::Agent` 回落(删了会让私有模块
//! 里的 `view` / `shell::nav::rail` 变死代码,`-D warnings` 会红)。
//! `pages::app` 现在**只服务 harness**:`shell` 的回调类型是 `crate::app::Message`,套不进
//! `pages::Message` 的树里。
//!
//! ## 截图通道
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- \
//! --screen=instances --width=1180 --height=760 \
//! --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 产品二进制的截图走 `--screenshot`(页面树由 app 层按路由装配),本 harness 只出**单页**图,
//! 用于页面级回归与宽度对照。
pub mod appearance;
pub mod frame;
pub mod harness;
pub mod layout;
pub mod model;
pub mod update;
pub mod account;
pub mod content;
mod download;
mod instances;
mod logs;
mod message;
mod nav;
mod screen;
// M101:设置页的两条漏斗(消息 / 状态)都从这个模块树里出——app 层要构造
// `settings::GroupMessage`、借用 `settings::Groups`,所以最小暴露面是 `pub(crate)`。
// **不是** `pub`:`private_interfaces` 按**类型自身的声明可见性**判(四个 `Message` 枚举与
// `GroupMessage` 声明成 `pub` 就够),把整个设置页模块树暴露成 crate 公开 API 没有回报。
pub(crate) mod settings;
#[cfg(test)]
mod tests;
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
pub(crate) use instances::demo_form as demo_launch_form;
pub use instances::{
discovered_run_toast, mods_failure_line, mods_from_state, saves_delete_prompt,
saves_failure_line, saves_from_state, InstanceMessage, LaunchForm, Mods, Saves,
};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message, UpdateAction};
pub use model::{Detail, Instances, LaunchFacts, Meta, Model, Row};
pub use screen::Screen;
pub use settings::Language;
pub use update::{Banner, Update, UpdateStatus};
use iced::widget::{column, container, row, space};
use iced::{Element, Length};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space;
use crate::app::components::motion;
use crate::theme::Ui;
use frame::filled;
/// **单页内容**(不含外壳):交给 `shell::view_with_content` 塞进真标题栏与导航轨之间。
///
/// 产品路径(`app/view.rs`)走 [`page_with_settings`]:外壳由 `shell` 画(拖动/缩放/三个
/// caption 键、rail 88),这里只画「页头 + 内容列」。列宽仍按**整个窗口宽**算
/// (`layout::frame_metrics` 内部会扣掉 rail 88 与页边距),所以外壳换了不影响版面判据。
///
/// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用:
/// `view(&self)` 不能返回一棵借用局部变量的树。
pub fn page<'a>(
ui: &'a Ui,
model: &'a Model,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(
ui,
model,
None,
settings::States {
appearance: None,
groups: settings::demo_groups(),
// harness 路径没有 presenter 快照:空切片 ⇒ 各组画演示行(`Model::settings` 此时是空的)。
settings: &model.settings,
},
Language::default(),
form,
)
}
/// 同 [`page`],但带上**外观状态**(设置页的「外观」分组要读当前值与诊断)。
///
/// 为什么另开一个入口而不是给 `Model` 加字段:`pages/model.rs` 是页面与 presenter 之间的展示
/// 契约(`Model` 只装「要画什么」),外观是**运行时状态**(滑块请求值、已解码的背景图、诊断),
/// 塞进去会让演示数据(`pages::demo`)也得伪造一份外观状态。
///
/// 这条入口拿的是**演示组状态**([`settings::demo_groups`]):页面层没有真组状态可用,
/// 产品路径走 [`page_with_settings`]。
pub fn page_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: &'a appearance::State,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(
ui,
model,
None,
settings::States {
appearance: Some(appearance),
groups: settings::demo_groups(),
settings: &model.settings,
},
Language::default(),
form,
)
}
/// 产品路径:带上**设置页那份只读状态**(外观运行期状态 + 各组自己的状态)。
///
/// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层只借用
/// 一帧;加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`)。`language` 是 M106 加的
/// (「当前档位 + 生效语言」只有 `App` 知道,设置页 General 组要画它);harness 那三条入口仍用
/// [`Language::default`]。
pub fn page_with_settings<'a>(
ui: &'a Ui,
model: &'a Model,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
page_with_settings_transition(ui, model, None, states, language, launch_form)
}
/// 同 [`page_with_settings`],但带上**上一帧的模型**(M256:换页时旧页要反向退场)。
///
/// 另开一条而不是加形参:上面那条是全仓(含 `pages::harness` / `pages_demo`)的既有调用点,
/// 它们没有旧页可传。`previous` 必须由调用方(`App`)持有——`Element<'a>` 会借它里面的字符串,
/// 线程局部的借用守卫活不过函数体。
pub fn page_with_settings_transition<'a>(
ui: &'a Ui,
model: &'a Model,
previous: Option<&'a Model>,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(ui, model, previous, states, language, launch_form)
}
fn render<'a>(
ui: &'a Ui,
model: &'a Model,
previous: Option<&'a Model>,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
// M192 / M256:主体入场(首屏 / 同级换页 / 进深层)。**这里只采样**——「标识变了就起跑」
// 在 update 侧(`App::sync_motion`):iced 先算订阅表后建视图,在 view 里插轨道当拍订阅不上。
let frame = motion::frame();
let current = motion::shifted(body(ui, model, states, language, launch_form), frame.enter);
// 旧页反向退场:退场腿没走完才画(`exiting` 一假就丢掉);`opaque` 让幽灵**吃掉**自己的点击。
let Some(older) = previous.filter(|_| frame.exiting) else {
return current;
};
let ghost = motion::shifted(body(ui, older, states, language, launch_form), frame.exit);
iced::widget::stack![iced::widget::opaque(ghost), current].into()
}
/// 一页的完整主体:页头 + 内容列,外加自更新提示条(M159)。
fn body<'a>(
ui: &'a Ui,
model: &'a Model,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
let page = match model.screen {
Screen::Instances => instances::view(ui, metrics, model, launch_form),
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
Screen::Download => download::view_sized(
ui,
metrics,
&model.download,
model.lang,
model.window_height,
),
// M109:内容库页同样跟 `model.lang`
// M161:内容库要按**窗口高度**选版面(矮窗口不能把列表压扁),
// 正文可用高由 `frame::body_height` 从窗口高减掉外壳算出(唯一来源)。
Screen::Content => {
content::view(ui, metrics, &model.content, model.lang, model.window_height)
}
// M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。
Screen::Settings => settings::view(ui, metrics, states, language, &model.update),
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
};
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
match update::banner(ui, &model.update, model.lang.catalog()) {
Some(banner) => column![banner, page]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill)
.into(),
None => page,
}
}
/// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。
///
/// 产品路径**不走这里**(它走 [`page`] + `shell::view_with_content`,用真标题栏)。这个入口留给
/// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的
/// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
pub fn app<'a>(
ui: &'a Ui,
model: &'a Model,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
app_with_appearance(ui, model, None, form)
}
/// 同 [`app`],但可以带上**外观运行期状态**。
///
/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section/`);
/// `None` ⇒ 画演示行,与 [`app`] 逐像素等价。harness 的 `--appearance=real` 走的就是这一支:
/// M100 之前 harness 恒传 `None`,于是 D30 要的「带真实外观控件」那一屏**根本不存在**——
/// 报告上写着「设置页已接真控件」,图里却只有演示行(U1 的形状)。
pub fn app_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: Option<&'a appearance::State>,
form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
// harness 路径没有真组状态:用演示状态([`settings::demo_groups`]),外观状态照传。
let body = render(
ui,
model,
None,
settings::States {
appearance,
groups: settings::demo_groups(),
settings: &model.settings,
},
Language::default(),
form,
);
let shell = row![nav::rail(ui, model.active), body]
.width(Length::Fill)
.height(Length::Fill);
let titled = column![titlebar_placeholder(ui), shell]
.width(Length::Fill)
.height(Length::Fill);
container(titled)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
/// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。
///
/// 实例 / 下载 / 日志三页带演示数据,设置页仍是占位视觉。
///
/// 高度也是模型的一部分(M161):内容库在矮窗口下要换版面,出图时 `--height` 必须
/// 真的传进来,否则「矮窗口那几张图」画的是高窗口的版面。
pub fn demo_model(screen: Screen, window_width: f32, window_height: f32) -> Model {
let mut model = Model::new(screen, window_width, window_height, Some(screen.nav()));
// 出图的中英取证(M222):`ULL_DL_LANG=zh` 时整页走中文目录。
//
// 为什么需要这一行:页面文案来自 `model.lang`(`download::view(.., lang)` → `lang.catalog()`),
// 而 `Model::new` 的 `lang` 写死 `En`、`demo_model` 之前也不碰它——所以 `LC_ALL=zh_CN.UTF-8`
// 对出图**完全无效**(`i18n::detect_env` 不在这条链上,Windows 上平台源还会压过 env)。
// 只有 harness / 单测走 `demo_model`,产品路径由 `app/view.rs` 从 `AppState` 建模型,零影响。
let lang = match std::env::var("ULL_DL_LANG").ok().as_deref().map(str::trim) {
Some("zh" | "zh_cn" | "zh-CN" | "zh_CN") => unlucky_i18n::Lang::ZhCn,
_ => unlucky_i18n::Lang::En,
};
model.lang = lang;
match screen {
Screen::Instances => model.instances = instances::demo(),
Screen::Download => model.download = download::demo(lang.catalog()),
Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang),
Screen::Logs => model.logs = logs::demo(model.lang),
// M159:设置页「更新」组的出图夹具(一个可装的新版本 + 一次成功检查)。
// 只给这一页:其它页的基线图(尤其实例页的启动参数区)不该因为更新提示条而整体下移。
Screen::Settings => model.update = update::demo(),
}
model
}
/// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)——**只给 [`app`] 的 harness 路径用**。
///
/// 产品路径的标题栏是 `shell::view_with_content` 画的那个真的(三个 caption 键 + 拖动热区),
/// 见模块文档偏离 #7(M90 r2 已修)。
fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> {
container(space::horizontal())
.width(Length::Fill)
.height(ui.metrics().titlebar_height)
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}