Files
Unlucky-Launcher/crates/app-iced/src/app/view/toast.rs
T
AskaEth 97768b7e3f 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 秒」胶囊)。
2026-11-13 13:18:11 +08:00

295 lines
10 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.
//! 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;