feat(app-iced): M90 —— app.rs 拆成 src/app/ + presenter 真接线 + 实例页端到端
拆分(行为不变,截图四件套逐字节相同):
- 400 行的 app.rs 拆成 src/app/{mod,state,message,update,subscription,view,boot,tests}.rs,
每个文件 ≤ 300 行;components 的 #[path] 跟着搬到 src/app/mod.rs(逻辑路径仍是 crate::app::components),
可见性放到 pub(crate) 让页面层能用真组件。
- 拆分前后各出 shell/gallery 浅深四张图,cmp 全部 IDENTICAL。
presenter 接线(crate 文档 §4):
- App 持有 Box<dyn Backend> + SettingsBridge + AppState;启动 build_backend + SettingsBridge::open + AppState::new。
- 截图通道强制 mock(D30 可复现);交互模式走真 host,--data-dir 从 M90 起真的被消费。
- Boot 抽干首屏快照(第 3 帧落盘,等节拍会把空页面拍进图);节拍 = 帧事件 + 33ms 时间闸门
(iced::time::every 要 tokio/smol,见 finding),tick → drain_events → AppState::apply。
- 用户动作不乐观回显:Launch/Stop 走 Backend::dispatch,状态等事件回来才变。
- 路由:pages::Message::Navigate(Route) 直接进 AppState::navigate,选中项 = 路由栈顶(不再有本地副本);
首屏自动选中第一行(list-detail 桌面惯例),只做一次。
实例页打通(其余三页保留占位视觉 + Message/update + 路由):
- 列表行换成 components::navigation::ListItem(真五态/选中),页头与详情动作换成 components::buttons
(Play 是唯一的实心 accent),分隔线换成 components::divider。
- 数据全部来自 presenter 快照:VERSION/LOADER/ISOLATION/VERSION ID/FILES/LAST PLAYED + 运行状态行。
- 导航轨换成真 NavigationRail(88),5 项各自压/替换路由。
版面约束未破(实测):列表列 336、正文列 424(元信息卡 206+12+206,右缘 896)、rail 88(药丸 16..71)、
断点 600/840 走 frame_metrics;新增 pages/tests.rs 的 9 条数值断言钉住它们。
This commit is contained in:
1 parent
270026338f
commit
b484998fc9
17 files changed
+1990
-676
No files matched your search
@@ -0,0 +1,295 @@
|
||||
//! `view`:presenter 快照 → 页面视图模型 → 控件树。
|
||||
//!
|
||||
//! 展示转换(快照字段 → 显示文本/网格)**全部在这里**,页面层只认 [`pages::Model`]。
|
||||
|
||||
use iced::{Element, Theme};
|
||||
|
||||
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::cli::Screen;
|
||||
use crate::pages::{self, Detail, Meta, Row};
|
||||
use crate::shell::{self, NavItem};
|
||||
|
||||
use super::message::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);
|
||||
}
|
||||
|
||||
match pages::Screen::of(self.state.route()) {
|
||||
Some(_) => pages::app(&self.ui, &self.view).map(Message::Page),
|
||||
// `Route::Agent`:AI 独立页还没有页面实现(P1),暂时回退到 M68 的外壳卡。
|
||||
// 这条回退也是 `src/shell/` 目前唯一的调用点——见报告里的「谁该补」。
|
||||
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 的快照转成页面视图模型(**唯一**的展示转换点)。
|
||||
pub(super) fn model(state: &AppState, width: f32) -> 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 },
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行的副文案:`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 {
|
||||
let loader = if instance.loader.kind == LoaderKind::Vanilla {
|
||||
"Vanilla".to_string()
|
||||
} else {
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
|
||||
assert_eq!(pages::Screen::of(state.route()), None);
|
||||
assert!(model.instances.detail.is_none());
|
||||
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user