fix(launch): M298 收尾失败不再静默、Toast 接上渲染点

三条提示通道各漏一半,用户才会「启动失败但找不到崩溃日志」:

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 秒」胶囊)。
This commit is contained in:
AskaEth committed 2026-11-13 13:18:11 +08:00
1 parent fc8f5c6794
commit 97768b7e3f
19 files changed
+1699 -553

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
//! Toast 覆盖层(M298):`state.toasts` 的**唯一渲染点**。
//!
//! ## 缺口有多真
//!
//! M297 盘查:`state.toasts` 全仓只被 **push**(安装取消 / 启动收尾 / mod 启停失败 / 复制成功)
//! 与 **expire**(`app/update/clock.rs`),`view.rs` 与 `pages/**` 对 `toasts` 零命中 ⇒ 提示一条
//! 都看不见。与「收尾失败静默不弹框」叠在一起,启动失败可以**完全无反馈**。
//!
//! ## 这一层画什么
//!
//! MD3E 胶囊:`InverseSurface` 底、`InverseOnSurface` 字、**`ShapeScale::Full`**(高的一半 ⇒
//! 真胶囊)、贴**底部居中**、多条自下而上叠(最新的那条在最下面,与推送顺序一致)。
//! 几何取组件层 [`SnackbarGeometry`](高 52 / 横内距 16 / 竖 6 / 间距 8 / 外边距 12 / 上限宽 600),
//! 不在这里写第二份数字。**不画阴影**:D27(CPU 档禁模糊)+ 组件层 D-4 的同一口径。
//!
//! ## 进 / 出过渡:复用模态那一对腿,不新造弹簧
//!
//! 进度由 [`ToastTracks`](super::super::state::toast::ToastTracks) 在 **update 侧**采样
//! (toast 自己的出生时刻 + `remaining_ms`),这里只把进度换成缩放:`motion::modal_scale`
//! 与 [`motion::scaled`]。档位、缓动、缩放起点全是既有令牌(D31:适配层不新造数)。
//! 关掉动效时进度恒 `1.0` ⇒ 恒等变换、出图逐像素不变。
//!
//! ## 为什么这一层不吃点击
//!
//! 它是一层铺满窗口的 `container`(`Fill`/`Fill`):iced 的 `Stack` 只在子控件
//! **捕获**事件时才停止向下派发,而普通容器不捕获 ⇒ 点到胶囊以外(以及胶囊本身)的按下照样
//! 落到下面的页面树。所以这一层不提供「点掉」交互:`ToastCenter::dismiss` 今天没有调用点,
//! 到期由 `clock.rs` 的 `expire` 统一处理(任务要求:过期仍走既有那条)。
use iced::advanced::layout::{self, Layout};
use iced::advanced::renderer;
use iced::advanced::widget::{tree, Tree};
use iced::advanced::{mouse, Clipboard, Shell, Widget};
use iced::widget::{container, row, text, Space};
use iced::{
Alignment, Background, Border, Element, Event, Length, Padding, Rectangle, Size, Vector,
};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use unlucky_presenter::toast::{Toast, ToastKind};
use crate::app::components::feedback::panel::SnackbarGeometry;
use crate::app::components::motion;
use crate::theme::Ui;
use super::super::message::Message;
use super::super::state::toast::ToastTracks;
/// 把 toast 覆盖层叠在 `base` 上(队列为空时原样返回 `base`)。
pub(super) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
toasts: &'a [Toast],
tracks: &'a ToastTracks,
) -> Element<'a, Message> {
if toasts.is_empty() {
return base;
}
let geometry = SnackbarGeometry::of(ui);
// 推送顺序 = 出现顺序:最早的在上,最新的贴底(MD3E 的堆叠方向)。
let mut column = iced::widget::Column::new()
.spacing(geometry.gap)
.align_x(Alignment::Center);
for toast in toasts {
column = column.push(capsule(ui, &geometry, toast, tracks.progress(toast.id)));
}
let plane = container(column)
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding::from(geometry.margin))
.align_x(Alignment::Center)
.align_y(Alignment::End);
let texts = toasts
.iter()
.map(|toast| toast.text.clone())
.collect::<Vec<_>>();
iced::widget::stack![base, tagged(plane.into(), texts)].into()
}
/// 一条胶囊:语义色的点 + 一行字(高度固定,字按词折行)。
fn capsule<'a>(
ui: &'a Ui,
geometry: &SnackbarGeometry,
toast: &'a Toast,
progress: f32,
) -> Element<'a, Message> {
let height = geometry.height_for(1);
let style = ui.style(TypeRole::BodyMedium);
let surface = ui.color(Role::InverseSurface);
// 语义色**只**落在前面那个点上:底与字必须保持 inverse 那一对(否则深色档对比度会掉)。
let accent = match toast.kind {
ToastKind::Info | ToastKind::Success => Role::InversePrimary,
ToastKind::Warn | ToastKind::Error => Role::Error,
};
// 点的直径取组件层的 **间距档**(8),圆角 `Full` ⇒ 正圆;不另写一个点径数字。
let dot_side = geometry.gap;
let dot: Element<'a, Message> = container(
Space::new()
.width(Length::Fixed(dot_side))
.height(Length::Fixed(dot_side)),
)
.style(move |_theme: &iced::Theme| container::Style {
background: Some(Background::Color(ui.color(accent))),
border: Border {
radius: ui.radius(ShapeScale::Full, dot_side),
..Border::default()
},
..container::Style::default()
})
.into();
let body = row![
dot,
text(toast.text.as_str())
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::InverseOnSurface))
.width(Length::Fill),
]
.align_y(Alignment::Center)
.spacing(geometry.gap);
let bar: Element<'a, Message> = container(body)
.width(Length::Fill)
.max_width(geometry.max_width)
.height(Length::Fixed(height))
.padding(Padding {
top: geometry.pad_v,
right: geometry.pad_h,
bottom: geometry.pad_v,
left: geometry.pad_h,
})
.style(move |_theme: &iced::Theme| container::Style {
background: Some(Background::Color(surface)),
border: Border {
radius: capsule_radius(ui, height),
..Border::default()
},
..container::Style::default()
})
.into();
motion::scaled(bar, motion::modal_scale(progress))
}
/// 胶囊圆角:`ShapeScale::Full` 对高求值(= 高的一半 ⇒ 真胶囊)。
///
/// 与组件层 snackbar 的 **16** 不是同一个口径(那是规格卡的矩形提示条);MD3E 的 toast 是
/// 胶囊,任务点名要 `ShapeScale::Full`,所以这里按**高的一半**算,且只在这一处求值。
fn capsule_radius(ui: &Ui, height: f32) -> iced::border::Radius {
ui.radius(ShapeScale::Full, height)
}
/// 覆盖层的**自证状态**:树里带着它 = 这些提示真的进了控件树。
///
/// 为什么需要它:iced 0.14 没有「把整棵树遍历成文本」的入口(`Element` 不透出内容、
/// `Widget::children()` 只给 [`Tree`],而 `Tree` 不带文字)。所以这一层把自己的**文案清单**
/// 作为 [`Widget::state`] 交出去——用例顺着 `Tree::state` 就能读到「画出来的到底是哪几句」,
/// 而不是只看 `state.toasts.len()`(那证明不了覆盖层被接上)。
#[derive(Debug)]
pub(super) struct CapsuleState {
/// 这一帧真正画出去的文案(顺序 = 自下而上的堆叠顺序)。
///
/// 只有用例读它(产品路径把状态交给 iced 就完事),所以非测试构建里显式放过 dead_code。
#[allow(dead_code)]
pub texts: Vec<String>,
}
/// 给覆盖层挂上自证状态(`tag` 用状态类型,见 iced 的 `container` / `text` 同一手法)。
pub(super) fn tagged<'a>(
content: Element<'a, Message>,
texts: Vec<String>,
) -> Element<'a, Message> {
Element::new(Tagging { content, texts })
}
struct Tagging<'a, Theme, Renderer> {
content: Element<'a, Message, Theme, Renderer>,
texts: Vec<String>,
}
impl<Theme, Renderer> Widget<Message, Theme, Renderer> for Tagging<'_, Theme, Renderer>
where
Renderer: iced::advanced::Renderer,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<CapsuleState>()
}
fn state(&self) -> tree::State {
tree::State::new(CapsuleState {
texts: self.texts.clone(),
})
}
fn children(&self) -> Vec<Tree> {
self.content.as_widget().children()
}
fn diff(&self, tree: &mut Tree) {
tree.state = self.state();
self.content.as_widget().diff(tree);
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content.as_widget_mut().layout(tree, renderer, limits)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(tree, renderer, theme, style, layout, cursor, viewport);
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn iced::advanced::widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(tree, layout, cursor, viewport, renderer)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<iced::overlay::Element<'a, Message, Theme, Renderer>> {
self.content
.as_widget_mut()
.overlay(tree, layout, renderer, viewport, translation)
}
}
#[cfg(test)]
mod tests;