Files
Unlucky-Launcher/crates/app-iced/src/app/view.rs
T
AskaEth 22e4c423fa feat(app-iced): M93 r2 —— 背景图真图直出 + 面板 α 收敛到 Ui::layer
复审前补齐两处(tower 扩 scope:Cargo.toml / pages/frame.* / theme.rs / presenter::prefs)。

1) 背景图**真图直出**
- `iced` 开 `image-without-codecs`(刻意不用 `image`:后者会打开 `image/default` 全套编解码器);
  编解码能力由本 crate 的 `image` features 决定,补了 `jpeg` / `bmp`(用户挑的背景图/壁纸多半是 jpg)。
- `pages/appearance/picture.rs`:`Cover`/`Contain` 走渲染器 `ContentFit`,`Tile` 按 `background::tiles`
  算行列(上限 64 块)真的铺 N 个图块;暗化 = 一层「版面底色 @ α=dim」叠在图上。
- 36dp 马赛克那条路**已删除**(不再是主路径,也不留死代码)。
- 踩坑记录:`Handle::from_bytes` 的纹理分配赶不上截图第 3 帧(实测画面全空),改用
  `Handle::from_path`(渲染器自己解码),已在 `picture::image_handle` 的文档里写明。

2) 面板 α **收敛到唯一来源**
- `Ui::layer()` 直接乘上面板 α(新增 `Ui::panel_alpha` / `with_panel_alpha`,默认 1.0、
  `scale_alpha(1.0)` 逐字节不变),`app::rebuild_ui` 注入生效值。
- 于是外壳(根容器/标题栏/导航轨)、`frame::card`(实例/下载/日志三页卡片)、
  `frame::header_bar`(新增底色)、外观分组卡片、空态图标块**全部同源**,
  调用点不再有任何 `panel_color(..)` 自乘(该 helper 已删)。
- 有背景图时根容器不再盖一层实色画布(否则 panel α=1 时背景图整块被挡)。

3) `presenter::prefs` 补重导出 `PANEL_ALPHA_MIN` / `BG_DIM_MAX`,app-iced 里两份 0.4 / 0.95
   字面量改为转发常量。

验收:11+3 张图(`C:\Temp\ull-shots\wt-93\r2-*.png`)——三档填充肉眼可分、`.jpg` 能加载、
GPU 档 panel55 时卡片/分组卡/页头带都透出背景、CPU 档仍强制 1.0 且背景在面板空隙可见;
另附实例/下载/日志三页 panel55 各一张,证明三页都吃到同一个 α。
2026-11-02 18:07:32 +08:00

326 lines
12 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.
//! `view`:presenter 快照 → 页面视图模型 → 控件树。
//!
//! 展示转换(快照字段 → 显示文本/网格)**全部在这里**,页面层只认 [`pages::Model`]。
use iced::{Element, Theme};
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage};
use unlucky_presenter::router::Route;
use unlucky_presenter::state::AppState;
use crate::app::components::interaction::Interaction;
use crate::cli::Screen;
use crate::pages::{self, Detail, Meta, Row};
use crate::shell::{self, NavItem};
use super::message::{nav_item_of, Message};
use super::state::{log_first_frame, App};
use super::APP_TITLE;
impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
pub(super) fn theme(&self) -> Theme {
self.ui().theme()
}
/// 窗口标题(自绘标题栏另画一份,这里是给系统/截图工具看的)。
pub(super) fn title(&self) -> String {
APP_TITLE.to_owned()
}
/// 整棵树。
pub(super) fn view(&self) -> Element<'_, Message> {
log_first_frame();
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
// 这里只做一次 map,组件库与业务 Message 依然解耦。
if self.options.screen == Screen::Gallery {
return super::components::gallery::view(&self.ui).map(Message::Demo);
}
// 产品页走**内容槽位**:外壳(标题栏 / 拖动与缩放热区 / 三个 caption 键 / rail)由
// `shell` 画,中间那块才是页面。M90 r2 之前这里用的是 `pages::app`(自带一条**没有任何
// 控件**的标题栏留白带),而 `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` /
// `Close` 五个消息只在外壳里构造 ⇒ 产品页「拖不动、缩不了、点不到关闭」。
//
// M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读
// 的那份状态本身(`page_with_appearance`)。
match pages::Screen::of(self.state.route()) {
Some(_) => {
let background =
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
.map(|element| element.map(Message::Page));
shell::view_with_background(
&self.ui,
nav_item_of(self.state.route()),
APP_TITLE,
background,
pages::page_with_appearance(&self.ui, &self.view, &self.appearance)
.map(Message::Page),
self.options.force_state,
)
}
// `Route::Agent`:AI 独立页还没有页面实现(P1),回退到 M68 的外壳卡(同一套装饰)。
None => shell::view(
&self.ui,
NavItem::Ai,
APP_TITLE,
NavItem::Ai.label(),
"The assistant page is not built yet (P1). Chat lives in the docked panel once \
the AI mission lands; this screen is the M68 skeleton fallback.",
self.options.force_state,
),
}
}
}
// --------------------------------------------------------------- 快照 → 视图模型
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
///
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
pub(super) fn model(
state: &AppState,
width: f32,
forced: Option<crate::cli::ForcedState>,
) -> pages::Model {
let route = state.route().clone();
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
let rows: Vec<Row> = state
.tiles()
.into_iter()
.map(|cell| Row {
id: cell.instance.id.clone(),
name: cell.instance.name.clone(),
supporting: supporting_line(cell.instance),
status: runtime_status(&cell.instance.runtime),
})
.collect();
let detail = match &route {
Route::Detail(id) => state
.instances
.iter()
.find(|instance| &instance.id == id)
.map(detail_of),
_ => None,
};
pages::Model {
screen,
window_width: width,
active: route.nav(),
instances: pages::Instances { rows, detail },
forced: forced.map(|state| match state {
crate::cli::ForcedState::Hover => Interaction::Hovered,
crate::cli::ForcedState::Pressed => Interaction::Pressed,
}),
}
}
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版」,见 `LoaderInfo::badge`)。
fn supporting_line(instance: &InstanceSummary) -> String {
format!("{} · {}", instance.mc_version, instance.loader.badge())
}
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
fn runtime_status(runtime: &InstanceRuntime) -> Option<String> {
match runtime {
InstanceRuntime::Idle => None,
InstanceRuntime::Busy { stage, progress } => Some(format!(
"{} · {:.0}%",
stage_label(*stage),
progress.ratio() * 100.0
)),
InstanceRuntime::Running { pid } => Some(format!("Running · pid {pid}")),
InstanceRuntime::Failed { reason } => Some(format!("Launch failed · {reason}")),
}
}
/// 阶段文案(英文基线;i18n mission 接手后统一走词表)。
///
/// 不复用 `LaunchStage::label()`:那是中文,而本 crate 的界面文案在 i18n 之前是英文基线
/// (见 `pages/instances/data.rs` 的同一条理由:干净 CI 上没有中文字体)。
fn stage_label(stage: LaunchStage) -> &'static str {
match stage {
LaunchStage::Download => "Downloading",
LaunchStage::Verify => "Verifying",
LaunchStage::Launch => "Starting",
LaunchStage::Window => "Waiting for window",
}
}
/// 详情列的内容(字段全部来自 [`InstanceSummary`])。
fn detail_of(instance: &InstanceSummary) -> Detail {
// 视觉 P2-2(M90 r2):列表行与详情曾经「同物两名」(列表 `badge()` 出「原版」、详情硬编码
// 「Vanilla」)。现在两边都走 `LoaderInfo::badge()` 这一个入口;等 i18n 落地后由词表统一,
// 届时两处会同时变——这正是要的。
let loader = instance.loader.badge();
Detail {
id: instance.id.clone(),
name: instance.name.clone(),
subtitle: supporting_line(instance),
meta: vec![
meta("VERSION", &instance.mc_version),
meta("LOADER", &loader),
meta("ISOLATION", &instance.isolation),
meta("VERSION ID", &instance.version_id),
meta(
"FILES",
if instance.version_present {
"Present on disk"
} else {
"Missing"
},
),
meta("LAST PLAYED", &last_played(instance.last_played.as_deref())),
],
// 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段
note: None,
status: runtime_status(&instance.runtime),
can_launch: matches!(
instance.runtime,
InstanceRuntime::Idle | InstanceRuntime::Failed { .. }
),
can_stop: matches!(
instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
),
}
}
fn meta(label: &str, value: &str) -> Meta {
Meta {
label: label.to_string(),
value: value.to_string(),
}
}
/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。
///
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等 i18n/时间格式 mission。
/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。
pub(super) fn last_played(raw: Option<&str>) -> String {
let Some(text) = raw else {
return "Never".to_string();
};
match (text.get(0..10), text.get(11..16)) {
(Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => {
format!("{date} {time} UTC")
}
_ => text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{InstanceSummary, LoaderInfo, LoaderKind, Progress};
use unlucky_presenter::mock_data::mock_instances;
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary {
id: "survival".to_string(),
name: "Survival".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: Some("2026-10-29T19:22:00Z".to_string()),
runtime,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
}
}
#[test]
fn the_snapshot_becomes_the_page_model() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()),
));
state.navigate(Route::Detail("survival".to_string()));
let model = model(&state, 1180.0, None);
assert_eq!(model.screen, pages::Screen::Instances);
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
assert_eq!(model.window_width, 1180.0);
assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例");
assert_eq!(model.instances.rows[0].id, "survival");
let detail = model.instances.detail.expect("路由是 Detail,要有详情");
assert_eq!(detail.id, "survival");
assert_eq!(detail.meta.len(), 6);
assert!(detail.can_launch, "空闲实例可启动");
assert!(!detail.can_stop);
assert!(detail.note.is_none(), "真快照没有正文段");
}
#[test]
fn a_busy_instance_offers_stop_instead_of_play() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance(
InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: 750,
total: 1_500,
},
},
)]),
));
state.navigate(Route::Detail("survival".to_string()));
let model = model(&state, 1180.0, None);
assert_eq!(
model.instances.rows[0].status.as_deref(),
Some("Downloading · 50%")
);
let detail = model.instances.detail.expect("有详情");
assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)");
assert!(detail.can_stop, "启动中要能 Stop");
}
#[test]
fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime).as_deref(),
Some("Launch failed · exit 1")
);
assert!(detail_of(&failed).can_launch, "失败后要能重试");
}
#[test]
fn timestamps_are_sliced_not_guessed() {
assert_eq!(
last_played(Some("2026-10-29T19:22:00Z")),
"2026-10-29 19:22 UTC"
);
assert_eq!(last_played(None), "Never");
// 短串 / 非 ASCII 原样返回(不 panic)
assert_eq!(last_played(Some("2026-10")), "2026-10");
assert_eq!(last_played(Some("昨天")), "昨天");
}
#[test]
fn the_agent_route_has_no_page() {
let mut state = AppState::new();
state.navigate(Route::Agent);
let model = model(&state, 1180.0, None);
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
assert_eq!(pages::Screen::of(state.route()), None);
assert!(model.instances.detail.is_none());
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
}
}