feat(m187): 启动失败弹窗 + 诊断包导出,并接通 DialogQueue
用户实测「MC 启动失败找不到崩溃日志」的直接修法:失败时弹一个框,把退出码、 一句人话原因、证据形态、四条真实路径与三个按钮摆在同一个地方。 - app-iced:最小一层模态(遮罩 + 卡片 + 主/次按钮 + Esc/点遮罩关闭), 消费 presenter 的 DialogQueue(此前全仓零引用,所有弹窗都画不出来); 队列管弹窗,CrashCard 管这次失败的事实,按 DialogId 绑定。 - 弹窗区分两种失败:游戏自己写了 crash-reports(贴 Description + 栈片段)/ 游戏没来得及写(明说「这次没有生成崩溃报告」并贴出捕获的最后 30 行)/ 什么都没抓到(给最可能原因分类)。绝不对第二种说「找不到日志」。 - 导出诊断包:core 侧新 crash_bundle 写 zip(清单 + 启动器日志 + 会话 JSONL + crash-reports/* + hs_err + latest.log + 版本 JSON),落盘即脱敏(家目录 / token / 会话 id),单文件 4 MiB、总量 48 MiB 上限并在清单记截断。 - 启动器自身日志落盘(core::launch::launcher_log → <.ULL>/logs/launcher.log, 登记目录前先缓冲),补上「双击启动时诊断行直接蒸发」的缺口。 - i18n:25 条中英文案进 catalog/logs.rs + 金表。 - 取证:ULL_DIALOG_FIXTURE=crash-report|no-logs|export 在临时目录造真文件出图, wgpu 与 tiny-skia 各一套,见 DevDocs/artifacts/m187/。 未复现 M190 的「owned 文字在 wgpu 下丢字」:同一串中文两条文字路径、两种后端逐字相同 (ab-wgpu.png / ab-tinyskia.png),因此本轮未改任何渲染路径。
This commit is contained in:
1 parent
29da7e2e47
commit
414f6ff886
23 files changed
+2818
-10
No files matched your search
@@ -0,0 +1,275 @@
|
||||
//! 模态层的控件树:**遮罩 + 卡片 + 主/次按钮**,正文与路径全部来自 [`super::Modal`]。
|
||||
//!
|
||||
//! ## 命中语义(为什么点遮罩能关、点卡片不关)
|
||||
//!
|
||||
//! `iced::widget::Stack` 的 `update` 从上往下(后画的先收)派发,遇到 `shell.is_event_captured()`
|
||||
//! 立刻停。所以三层的顺序是:
|
||||
//!
|
||||
//! ```text
|
||||
//! stack![ 页面树, 遮罩(mouse_area → Dismiss), 卡片(mouse_area → Swallow) ]
|
||||
//! ```
|
||||
//!
|
||||
//! 卡片那层用 `mouse_area` 把左键按下**吃掉**([`super::Message::Swallow`]),于是点卡片里的
|
||||
//! 空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获,连 Swallow 都不会发。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Font, Length, Pixels, Shadow};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::spacing::Space as Sp;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::app::components::buttons;
|
||||
use crate::pages::frame;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::{Message as DialogMessage, Modal};
|
||||
|
||||
/// 卡片最大宽(对话框档:单列 560,窄窗按视口收)。
|
||||
const CARD_WIDTH: f32 = 560.0;
|
||||
/// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。
|
||||
const SCROLL_HEIGHT: f32 = 300.0;
|
||||
/// 卡片最大高(兜底:不许把边框撑出屏幕)。
|
||||
const CARD_HEIGHT: f32 = 620.0;
|
||||
/// 遮罩不透明度(Material scrim 档;0.32 时下面的页面还看得见轮廓)。
|
||||
const SCRIM_ALPHA: f32 = 0.32;
|
||||
|
||||
/// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。
|
||||
pub(crate) fn layer<'a>(
|
||||
ui: &'a Ui,
|
||||
base: Element<'a, Message>,
|
||||
modal: &'a Modal,
|
||||
) -> Element<'a, Message> {
|
||||
let scrim = mouse_area(
|
||||
container(Space::new())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(scrim_color(ui))),
|
||||
..container::Style::default()
|
||||
}),
|
||||
)
|
||||
.on_press(Message::Dialog(DialogMessage::Dismiss));
|
||||
|
||||
let overlay = mouse_area(
|
||||
container(card(ui, modal))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(ui.space(Sp::X6)),
|
||||
)
|
||||
.on_press(Message::Dialog(DialogMessage::Swallow));
|
||||
|
||||
iced::widget::stack![base, scrim, overlay].into()
|
||||
}
|
||||
|
||||
fn scrim_color(ui: &Ui) -> Color {
|
||||
Color {
|
||||
a: SCRIM_ALPHA,
|
||||
..ui.color(Role::OnSurface)
|
||||
}
|
||||
}
|
||||
|
||||
fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
|
||||
let shape = Component::OutlinedCard.spec().shape;
|
||||
|
||||
// 头(标题 + 三句正文)与脚(按钮)**固定**,中间那段滚——按钮滚出屏幕等于功能不存在
|
||||
// (视觉复审在日志页踩过同一坑:诊断卡把摘要页脚顶出去了)。
|
||||
let mut header = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
|
||||
header = header.push(role(
|
||||
ui,
|
||||
TypeRole::HeadlineSmall,
|
||||
true,
|
||||
modal.title.as_str(),
|
||||
));
|
||||
for line in &modal.body {
|
||||
header = header.push(wrapped(ui, line.as_str(), ui.color(Role::OnSurface)));
|
||||
}
|
||||
|
||||
let mut middle = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
|
||||
if let Some(card) = &modal.card {
|
||||
// **详细说明在前、路径在后**:用户先要知道「发生了什么」,再要「文件在哪」。
|
||||
if card.expanded {
|
||||
if let Some(title) = &card.detail_title {
|
||||
middle = middle.push(role(ui, TypeRole::LabelMedium, false, title.as_str()));
|
||||
}
|
||||
for line in &card.detail_lines {
|
||||
middle = middle.push(mono(ui, line.clone()));
|
||||
}
|
||||
middle = middle.push(frame::divider(ui).map(Message::Page));
|
||||
}
|
||||
for (index, (label, path)) in card.paths.iter().enumerate() {
|
||||
middle = middle.push(
|
||||
button(
|
||||
column![
|
||||
role(ui, TypeRole::LabelMedium, false, label.as_str()),
|
||||
mono(ui, path.clone()),
|
||||
]
|
||||
.spacing(0.0)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(0)
|
||||
.style(|_theme, _status| button::Style {
|
||||
background: None,
|
||||
text_color: Color::TRANSPARENT,
|
||||
border: Border::default(),
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
})
|
||||
.on_press(Message::Dialog(DialogMessage::CopyPath(index))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut footer = column![].spacing(ui.space(Sp::X1)).width(Length::Fill);
|
||||
if let Some(notice) = &modal.notice {
|
||||
footer = footer.push(wrapped(ui, notice.as_str(), ui.color(Role::Primary)));
|
||||
}
|
||||
footer = footer.push(actions(ui, modal));
|
||||
|
||||
// 中间那段给**定高**预算:`Shrink` 会让滚动区按内容高排版,把页脚顶到卡片外面
|
||||
// (实测第一版就是这样:导出/关闭两个按钮整排不可见)。
|
||||
let body = if modal.card.is_some() {
|
||||
column![
|
||||
header,
|
||||
scrollable(middle).height(Length::Fixed(SCROLL_HEIGHT)),
|
||||
frame::divider(ui).map(Message::Page),
|
||||
footer,
|
||||
]
|
||||
} else {
|
||||
column![header, frame::divider(ui).map(Message::Page), footer,]
|
||||
}
|
||||
.spacing(ui.space(Sp::X3))
|
||||
.width(Length::Fill);
|
||||
|
||||
container(body)
|
||||
.width(Length::Fixed(CARD_WIDTH))
|
||||
.max_width(CARD_WIDTH + 48.0)
|
||||
.max_height(CARD_HEIGHT + 120.0)
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
|
||||
border: ui.outlined(shape),
|
||||
shadow: Shadow {
|
||||
color: Color {
|
||||
a: 0.18,
|
||||
..Color::BLACK
|
||||
},
|
||||
offset: iced::Vector::new(0.0, 8.0),
|
||||
blur_radius: 24.0,
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 底部按钮区:**两行**——辅助操作(开文件夹 / 复制路径 / 展开)在上,主次按钮在下靠右。
|
||||
///
|
||||
/// 为什么分两行:五个按钮塞一行在 560 的卡宽里会把主按钮压成竖排(实测第一版就是这样,
|
||||
/// 「导出诊断包」被挤成两行单字)。
|
||||
fn actions<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
|
||||
let mut column_actions = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
|
||||
|
||||
if let Some(card) = &modal.card {
|
||||
column_actions = column_actions.push(
|
||||
row![
|
||||
buttons::outlined(
|
||||
ui,
|
||||
buttons::Button::new(
|
||||
card.actions[1].as_str(),
|
||||
Message::Dialog(DialogMessage::OpenFolder),
|
||||
),
|
||||
),
|
||||
buttons::outlined(
|
||||
ui,
|
||||
buttons::Button::new(
|
||||
card.actions[2].as_str(),
|
||||
Message::Dialog(DialogMessage::CopyPaths),
|
||||
),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
buttons::Button::new(
|
||||
card.toggle.as_str(),
|
||||
Message::Dialog(DialogMessage::Toggle),
|
||||
),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Sp::X2))
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
|
||||
let mut line = row![Space::new().width(Length::Fill)].spacing(ui.space(Sp::X2));
|
||||
if let Some(secondary) = &modal.secondary {
|
||||
line = line.push(buttons::text(
|
||||
ui,
|
||||
buttons::Button::new(
|
||||
secondary.as_str(),
|
||||
Message::Dialog(DialogMessage::Secondary),
|
||||
),
|
||||
));
|
||||
}
|
||||
if !modal.blocking_progress {
|
||||
line = line.push(buttons::filled(
|
||||
ui,
|
||||
buttons::Button::new(
|
||||
modal.primary.as_str(),
|
||||
Message::Dialog(DialogMessage::Primary),
|
||||
),
|
||||
));
|
||||
}
|
||||
column_actions.push(line.align_y(Alignment::Center)).into()
|
||||
}
|
||||
|
||||
/// 令牌排版档的文字(`pages::frame` 的那一套,映射成本层的消息类型)。
|
||||
fn role<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
emphasized: bool,
|
||||
content: &'a str,
|
||||
) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
role,
|
||||
emphasized,
|
||||
content,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
.map(Message::Page)
|
||||
}
|
||||
|
||||
/// 等宽文字(路径 / 堆栈 / 捕获输出——这些必须能一眼看出字符边界)。
|
||||
fn mono<'a>(ui: &'a Ui, content: String) -> Element<'a, Message> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
text(content)
|
||||
.size(style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||
.font(Font {
|
||||
family: iced::font::Family::Name(ui.families().mono),
|
||||
..Font::DEFAULT
|
||||
})
|
||||
.color(ui.color(Role::OnSurface))
|
||||
.width(Length::Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 正文一段:令牌排版档 + 按词折行(长路径只按词折会把卡片顶破)。
|
||||
fn wrapped<'a>(ui: &'a Ui, content: &'a str, color: Color) -> Element<'a, Message> {
|
||||
let style = ui.style(TypeRole::BodyMedium);
|
||||
text(content)
|
||||
.size(style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||
.font(ui.font_of(style))
|
||||
.color(color)
|
||||
.width(Length::Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||
.into()
|
||||
}
|
||||
Reference in new issue
Block a user