三条提示通道各漏一半,用户才会「启动失败但找不到崩溃日志」: 1) 收尾失败静默不弹:finish() 报错那条出口把 LaunchFinished.session 发成真实 会话,而它登记的现场走 before_start 写死空串 ⇒ dialog.rs 的并发守卫 (!finished.session.is_empty() && report.session != finished.session)成立后 return。改成**会话归属单一来源**:before_start 新增可选 SessionAttribution, presenter 的 publish_before_start 把它写进报告后**返回同一个值**给 DTO; 两处不可能再各写一遍。守卫原样保留(A 场的现场不许解释 B 场,有用例钉住)。 2) Toast 只 push 不渲染:新增 app/view/toast.rs 覆盖层(MD3E 胶囊 = ShapeScale::Full,底部居中,InverseSurface 底),接在页面树之上、模态之下; 过期仍走 clock.rs 的 expire;进/出过渡复用模态那一对腿(motion::modal_spec / modal_scale / scaled),时间轴在 update 侧由 state/toast.rs 采样,关掉动效时 恒为终值(出图可复现)。两个拷贝路径补上 view_dirty(推了不置脏要等下一次事件)。 3) 三个贴线文件先拆再改(纯搬移 + pub use 保路径,机器核对:搬出的 14 个函数体 逐行相同):crash_report.rs 394→318(+context.rs / slot.rs)、 host/launch.rs 399→233(+build.rs)、app/dialog.rs 390→178(+model.rs)。 证据:DevDocs/artifacts/m298/toast-capsule.png(--screenshot + ULL_TOAST_FIXTURE, 图上可见「游戏崩溃 · 12.0 秒」胶囊)。
174 lines
8.7 KiB
Rust
174 lines
8.7 KiB
Rust
//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。
|
||
//!
|
||
//! 只有两类副作用:**剪贴板**(返回 `Task`,必须交回 runtime)与**弹 Toast**(就地推)。
|
||
//! 导出与打开文件夹是同步的本地动作(写一个 zip / 拉起一个 `explorer`),耗时都在毫秒级,
|
||
//! 不为它们单开线程——真要慢(网络盘)也是用户能感知的一次点击。
|
||
|
||
use iced::Task;
|
||
use unlucky_presenter::toast::ToastKind;
|
||
|
||
use crate::app::components::motion;
|
||
use crate::app::dialog::{export, Message as DialogMessage};
|
||
use crate::app::state::App;
|
||
|
||
/// 关掉当前这个框(**唯一出口**,M256)。
|
||
///
|
||
/// 逻辑状态是**当场**关的(`close_dialog()` 立刻清 `state.dialogs`)——延迟关闭会让队列里那个
|
||
/// 框在退场期间仍然"活着"(`current()` 还是它),所有按"有没有框"判断的代码都会看到一个已经
|
||
/// 点过「确定」的框。所以退场只发生在**画面上**:`refresh_view` 在 `motion::modal_closing()`
|
||
/// 期间保留旧框的视图模型,`App::sync_motion` 在退场走完那一拍把它置 `None`。
|
||
///
|
||
/// 关着动效(`MotionScale::NONE` / 截图通道)时 `begin_modal_close()` 返回 `true`,
|
||
/// `modal_closing()` 恒假 ⇒ 当场清,退回改造前的行为。
|
||
fn close(app: &mut App) {
|
||
let _ = motion::begin_modal_close();
|
||
app.close_dialog();
|
||
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||
// (r1 复审 P3-1)。退场的第一帧必须在这一拍画出来。
|
||
app.refresh_view();
|
||
}
|
||
|
||
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
|
||
// M256:退场动画期间**吞掉**弹窗消息。框正在缩小,这时再点一次「删除世界」不该再生效
|
||
// (主按钮的 `take_confirm` 已经在上一次点击里核销过目标,但用户看到的仍是"框还在")。
|
||
if motion::modal_closing() {
|
||
return Task::none();
|
||
}
|
||
match message {
|
||
DialogMessage::Primary => {
|
||
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
|
||
// 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。
|
||
//
|
||
// **认框不认人**(复审 r1 P1-1):只核销"当前这个框"登记的目标。哪怕某条关闭路径漏清,
|
||
// 崩溃卡片的「导出诊断包」也拿不走它——否则就是一次静默删世界。
|
||
let current = app.state.dialogs.current().map(|request| request.id);
|
||
if let Some(dir) = current.and_then(|id| app.state.saves.take_confirm(id)) {
|
||
// M256:走统一出口(逻辑当拍关,画面多留 150ms 退场)。
|
||
// 目标已经在上一行核销 ⇒ 退场期间再点一次主按钮不会删第二个世界
|
||
// (那一拍的消息还会被 `modal_closing` 那道闸门吞掉)。
|
||
close(app);
|
||
if let Some(instance) = app.selected_instance_id() {
|
||
app.state
|
||
.saves
|
||
.mark_pending(unlucky_presenter::state::SavesOp::Delete {
|
||
dir: dir.clone(),
|
||
});
|
||
app.dispatch(unlucky_presenter::dto::AppCommand::Saves(Box::new(
|
||
unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir },
|
||
)));
|
||
}
|
||
return Task::none();
|
||
}
|
||
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
|
||
let Some(card) = app.dialog_card.clone() else {
|
||
close(app);
|
||
return Task::none();
|
||
};
|
||
let done = export::export(&card.report, app.lang());
|
||
// 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast
|
||
// (app-iced 的 Toast 至今没有渲染点)。成败按 `write_bundle` 的真实结果判,
|
||
// 不看「launcher_logs 有没有值」——那只是入参齐全度(r1 复审 P3-2)。
|
||
app.state.toasts.push(
|
||
if done.ok {
|
||
ToastKind::Success
|
||
} else {
|
||
ToastKind::Error
|
||
},
|
||
done.text.clone(),
|
||
);
|
||
if let Some(state) = &mut app.dialog_card {
|
||
state.outcome = Some(done.text);
|
||
}
|
||
app.view_dirty = true;
|
||
app.refresh_view();
|
||
Task::none()
|
||
}
|
||
DialogMessage::Secondary | DialogMessage::Dismiss => {
|
||
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
|
||
// **只核销当前这个框的目标**(复审 r2 加固):别人的框关掉时不该动我们的。
|
||
if let Some(current) = app.state.dialogs.current().map(|request| request.id) {
|
||
app.state.saves.close_confirm(current);
|
||
}
|
||
// M256:统一出口(有动效时先退场)。「立即重画」这条要求由 `close` 里的
|
||
// `refresh_view` 满足:退场的第一帧必须在这一拍画出来。
|
||
close(app);
|
||
Task::none()
|
||
}
|
||
DialogMessage::Toggle => {
|
||
app.toggle_dialog_detail();
|
||
Task::none()
|
||
}
|
||
DialogMessage::OpenFolder => {
|
||
let Some(card) = app.dialog_card.clone() else {
|
||
return Task::none();
|
||
};
|
||
let problem = export::open_folder(&card.report, app.lang());
|
||
if !problem.is_empty() {
|
||
if let Some(state) = &mut app.dialog_card {
|
||
state.outcome = Some(problem.clone());
|
||
}
|
||
app.state.toasts.push(ToastKind::Error, problem);
|
||
app.view_dirty = true;
|
||
app.refresh_view();
|
||
}
|
||
Task::none()
|
||
}
|
||
DialogMessage::CopyPaths => {
|
||
let Some(card) = app.dialog_card.clone() else {
|
||
return Task::none();
|
||
};
|
||
app.state.toasts.push(
|
||
ToastKind::Info,
|
||
app.lang()
|
||
.catalog()
|
||
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
||
);
|
||
// M298:提示有了渲染点之后,「推了不置脏」= 要等下一次事件才出现(拷贝成功尤其
|
||
// 刺眼:用户盯着屏幕等一句"已复制")。两条拷贝路径都在这里补上。
|
||
app.view_dirty = true;
|
||
iced::clipboard::write(export::paths_text(&card.report))
|
||
}
|
||
DialogMessage::CopyPath(index) => {
|
||
let Some(card) = app.dialog_card.clone() else {
|
||
return Task::none();
|
||
};
|
||
let Some((_, path)) = card.report.path_lines().into_iter().nth(index) else {
|
||
return Task::none();
|
||
};
|
||
app.state.toasts.push(
|
||
ToastKind::Info,
|
||
app.lang()
|
||
.catalog()
|
||
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
||
);
|
||
// M298:同 `CopyPaths`(推了必须置脏,否则这一条要等下一次事件)。
|
||
app.view_dirty = true;
|
||
iced::clipboard::write(path.display().to_string())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
|
||
pub(super) fn escape(app: &mut App) -> bool {
|
||
// M256:退场窗口里 Esc **也算被吃掉**。`on_dialog` 那道闸门管不到这里(Esc 走的是
|
||
// `Message::Agent(Key)` 这条独立路径),而逻辑状态已经关掉 ⇒ 不加这一行,Esc 会落到
|
||
// AI 栏去收起面板,用户看到的是「框还在缩、AI 栏先没了」。
|
||
if motion::modal_closing() {
|
||
return true;
|
||
}
|
||
let Some(current) = app.state.dialogs.current().map(|request| request.id) else {
|
||
return false;
|
||
};
|
||
// M247(复审 r1 P1-1):**三条关闭路径都要清**——次按钮/遮罩清了,`Esc` 这条以前漏了,
|
||
// 于是"删存档 → Esc → 崩溃卡片 → 导出诊断包"会把那个世界静默删掉。
|
||
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
|
||
// 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。
|
||
app.state.saves.close_confirm(current);
|
||
// M256:统一出口(Esc 也走退场动画,而不是硬跳)。
|
||
close(app);
|
||
true
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|