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,157 @@
|
||||
//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。
|
||||
//!
|
||||
//! 与 [`super`] 分开是因为职责不同:那边管**弹窗与事实**,这边管**把事实带出界面**。
|
||||
//! 打包本体在 `unlucky_core::launch::crash_bundle`(脱敏、上限、清单都在那一处),
|
||||
//! 本文件只负责「放哪、叫什么名字、怎么打开」。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::launch::crash_bundle::{self, BundleEntry, BundleSpec};
|
||||
use unlucky_core::launch::crash_report::CrashReport;
|
||||
use unlucky_i18n::catalog::{Catalog, Key};
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
/// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。
|
||||
///
|
||||
/// 返回给界面的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」)。
|
||||
pub(crate) fn export(report: &CrashReport, lang: Lang) -> String {
|
||||
let catalog = Catalog::of(lang);
|
||||
let Some(logs) = &report.launcher_logs else {
|
||||
return catalog.render(
|
||||
Key::CrashDialogExportFailed,
|
||||
&[("error", "no launcher log dir")],
|
||||
);
|
||||
};
|
||||
let stamp =
|
||||
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now())
|
||||
.replace([':', '.', 'T'], "-")
|
||||
.trim_end_matches('Z')
|
||||
.to_string();
|
||||
let name = if report.session.is_empty() {
|
||||
&report.instance
|
||||
} else {
|
||||
&report.session
|
||||
};
|
||||
let target = crash_bundle::default_target_dir(logs).join(format!("crash-{name}-{stamp}.zip"));
|
||||
match crash_bundle::write_bundle(&spec_of(report), &target) {
|
||||
Ok(bundle) => catalog.render(
|
||||
Key::CrashDialogExportDone,
|
||||
&[
|
||||
("bytes", &bundle.bytes.to_string()),
|
||||
("path", &bundle.path.display().to_string()),
|
||||
],
|
||||
),
|
||||
Err(error) => catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]),
|
||||
}
|
||||
}
|
||||
|
||||
/// 现场 → 诊断包清单(**进包的东西必须与弹窗上列出的路径一致**)。
|
||||
fn spec_of(report: &CrashReport) -> BundleSpec {
|
||||
let mut entries: Vec<BundleEntry> = Vec::new();
|
||||
let mut push = |group: &'static str, path: &std::path::Path| {
|
||||
if path.exists() {
|
||||
entries.push(BundleEntry::new(group, path));
|
||||
}
|
||||
};
|
||||
if let Some(logs) = &report.launcher_logs {
|
||||
push("launcher", &logs.join("launcher.log"));
|
||||
push("launcher", &logs.join("launcher.1.log"));
|
||||
push("launcher", &logs.join("install.log"));
|
||||
}
|
||||
if let Some(session) = &report.session_log {
|
||||
push("session", session);
|
||||
}
|
||||
for artifact in &report.artifacts {
|
||||
// `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。
|
||||
if artifact.source != "latest_log" {
|
||||
push("game", &artifact.path);
|
||||
}
|
||||
}
|
||||
if let Some(latest) = report.latest_log_path() {
|
||||
push("game", &latest);
|
||||
}
|
||||
if let Some(json) = &report.version_json {
|
||||
push("instance", json);
|
||||
}
|
||||
let mut secrets = vec![report.session.clone()];
|
||||
secrets.retain(|secret| !secret.is_empty());
|
||||
BundleSpec {
|
||||
instance: report.name.clone(),
|
||||
version: report.version.clone(),
|
||||
session: report.session.clone(),
|
||||
exit_code: report.exit_code,
|
||||
exit_type: report.exit_type.clone(),
|
||||
uptime_ms: report.uptime_ms,
|
||||
command: report.command.clone(),
|
||||
java: report.java.clone(),
|
||||
system: report.system.clone(),
|
||||
notes: report.note_lines(),
|
||||
missing: report.missing_lines(),
|
||||
entries,
|
||||
secrets,
|
||||
home: std::env::var("USERPROFILE")
|
||||
.or_else(|_| std::env::var("HOME"))
|
||||
.ok()
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开「日志文件夹」:有现场就开实例运行目录,否则开启动器日志目录。
|
||||
///
|
||||
/// 平台差异只在这里一处(`explorer` / `open` / `xdg-open`),不往业务代码里散写 `cfg`。
|
||||
pub(crate) fn open_folder(report: &CrashReport, lang: Lang) -> String {
|
||||
let catalog = Catalog::of(lang);
|
||||
let target = report
|
||||
.run_dir
|
||||
.clone()
|
||||
.filter(|dir| dir.is_dir())
|
||||
.or_else(|| report.launcher_logs.clone().filter(|dir| dir.is_dir()));
|
||||
let Some(dir) = target else {
|
||||
return catalog.render(
|
||||
Key::CrashDialogOpenFailed,
|
||||
&[("error", "no folder on disk")],
|
||||
);
|
||||
};
|
||||
match spawn_opener(&dir) {
|
||||
Ok(()) => String::new(),
|
||||
Err(error) => catalog.render(Key::CrashDialogOpenFailed, &[("error", &error)]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
|
||||
// `explorer` 不用 shell:路径里的空格与 `&` 不会被当成命令分隔符。
|
||||
std::process::Command::new("explorer")
|
||||
.arg(dir)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
|
||||
std::process::Command::new("open")
|
||||
.arg(dir)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(dir)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// 「复制路径」的一段文本(每条一行,标签 + 路径)。
|
||||
pub(crate) fn paths_text(report: &CrashReport) -> String {
|
||||
report
|
||||
.path_lines()
|
||||
.into_iter()
|
||||
.map(|(label, path)| format!("{label}: {}", path.display()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
|
||||
//!
|
||||
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
|
||||
//!
|
||||
//! | 用例 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`queue_drives_the_modal_through_refresh_view`] | 队列里有请求 ⇒ `refresh_view` 之后 `view()` 拿到的模型里就有它(把消费点删掉即红) | 像素:`view()` 是否真的把控件塞进 iced 树(由 `DevDocs/artifacts/m187/` 的截图守) |
|
||||
//! | [`runtime_chinese_reaches_the_body`] | 运行时拼出来的中文(`render` / `format!` 的 owned 串)确实在正文里 | 字形是否栅格化出来(同上,wgpu / tiny-skia 并排图) |
|
||||
//! | [`two_failure_shapes_say_two_different_things`] | 没有 crash-report 时**不会**说成「有崩溃报告」,而且必须贴出捕获行 | 那句话读起来顺不顺(review) |
|
||||
//!
|
||||
//! 「owned 文字在 wgpu 下丢字」这条 M190 的怀疑在本 branch 上**没有复现**(A/B 图见
|
||||
//! `DevDocs/artifacts/m187/ab-wgpu.png` 与 `ab-tinyskia.png`):同一串中文走 `text_role` 与
|
||||
//! `text_role_owned` 在两种后端下逐字相同。所以这里不编一条「为了变绿而改渲染路径」的断言。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
|
||||
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
|
||||
|
||||
use super::{current, path_rows, CrashCard, DialogRequestView};
|
||||
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn headless() -> App {
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
unlucky_design::color::SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
fn report(with_crash_report: bool, tail: Vec<String>) -> CrashReport {
|
||||
let run_dir = PathBuf::from("/instances/survival");
|
||||
let artifacts = if with_crash_report {
|
||||
vec![ArtifactFact {
|
||||
source: "crash_report".to_string(),
|
||||
path: run_dir.join("crash-reports/crash-2026-11-08.txt"),
|
||||
excerpt: Some("Description: Initializing game".to_string()),
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
CrashReport {
|
||||
instance: "survival".to_string(),
|
||||
name: "生存".to_string(),
|
||||
version: "1.20.4-fabric".to_string(),
|
||||
session: "9f2c".to_string(),
|
||||
session_log: Some(PathBuf::from("/sessions/game/9f2c.jsonl")),
|
||||
version_json: Some(PathBuf::from("/game/versions/1.20.4/1.20.4.json")),
|
||||
launcher_logs: Some(PathBuf::from("/logs")),
|
||||
run_dir: Some(run_dir),
|
||||
artifacts,
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".to_string(),
|
||||
uptime_ms: 1_000,
|
||||
command: "javaw -Xmx4096m".to_string(),
|
||||
java: "javaw.exe (21)".to_string(),
|
||||
system: "os=windows".to_string(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: tail,
|
||||
}
|
||||
}
|
||||
|
||||
/// 守卫①:队列消费。请求进队列 ⇒ `refresh_view` 之后模型里有它;空队列 ⇒ 没有。
|
||||
///
|
||||
/// **变异证明**:把 `refresh_view` 末尾那行 `self.dialog_model = self.dialog_modal();` 删掉,
|
||||
/// 本用例第二段立刻红(`dialog_model` 恒 `None`)。
|
||||
#[test]
|
||||
fn queue_drives_the_modal_through_refresh_view() {
|
||||
let mut app = headless();
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
app.dialog_model.is_none(),
|
||||
"空队列不该画任何弹窗(现状:队列零引用,弹窗从不渲染)"
|
||||
);
|
||||
|
||||
let title = app.lang().catalog().text(Key::CrashDialogTitleFailed);
|
||||
app.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
title,
|
||||
"正文一行",
|
||||
DialogKind::Confirm {
|
||||
primary: "主要".to_string(),
|
||||
secondary: "次要".to_string(),
|
||||
},
|
||||
);
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
|
||||
let modal = app.dialog_model.as_ref().expect("队列里有请求就必须有模型");
|
||||
assert_eq!(modal.title, title);
|
||||
assert_eq!(modal.primary, "主要");
|
||||
assert_eq!(modal.secondary.as_deref(), Some("次要"));
|
||||
}
|
||||
|
||||
/// 守卫②:**运行时拼出来的中文**必须真的进正文(owned 串走 `Catalog::render` / `format!`)。
|
||||
///
|
||||
/// **变异证明**:把 `failure_body` 里的 `catalog.render(Key::CrashDialogExitCode, …)` 换成固定
|
||||
/// 英文,本用例第一处断言立刻红。
|
||||
#[test]
|
||||
fn runtime_chinese_reaches_the_body() {
|
||||
let lang = Lang::ZhCn;
|
||||
let card = CrashCard {
|
||||
report: report(false, vec!["java.lang.RuntimeException: boom".to_string()]),
|
||||
expanded: true,
|
||||
dialog: 1,
|
||||
outcome: None,
|
||||
};
|
||||
let mut queue = DialogQueue::new();
|
||||
queue.push(
|
||||
DialogSource::UserAction,
|
||||
"游戏崩溃",
|
||||
"",
|
||||
DialogKind::Confirm {
|
||||
primary: String::new(),
|
||||
secondary: String::new(),
|
||||
},
|
||||
);
|
||||
let request = queue.current().expect("当前条");
|
||||
let view = DialogRequestView::of(request);
|
||||
let modal = current(lang, &view, Some(&card));
|
||||
let body = modal.body_text();
|
||||
|
||||
// 退出码那句是 `render`(带参数)拼出来的:必须带中文与数字。
|
||||
assert!(body.contains("退出码 1"), "{body}");
|
||||
assert!(body.contains("游戏进程起来了"), "{body}");
|
||||
// 没有 crash-report 时必须说「没生成」+「下面是捕获的行」。
|
||||
assert!(body.contains("没有生成崩溃报告"), "{body}");
|
||||
assert!(body.contains("捕获"), "{body}");
|
||||
// 英文界面里同样的三句不许出现中文(反向守卫:i18n 不是只往一个方向写)。
|
||||
let en = current(Lang::En, &view, Some(&card));
|
||||
assert!(!en.body_text().contains('崩'), "{}", en.body_text());
|
||||
}
|
||||
|
||||
/// 守卫③:两种失败**说两句不同的话**,而且都可以导出(事实齐全)。
|
||||
#[test]
|
||||
fn two_failure_shapes_say_two_different_things() {
|
||||
let lang = Lang::ZhCn;
|
||||
let with = report(true, vec!["stack line".to_string()]);
|
||||
let without = report(false, vec!["Error: could not find java.dll".to_string()]);
|
||||
|
||||
assert_ne!(with.evidence(), without.evidence());
|
||||
let with_card = CrashCard {
|
||||
report: with.clone(),
|
||||
expanded: true,
|
||||
dialog: 1,
|
||||
outcome: None,
|
||||
};
|
||||
let without_card = CrashCard {
|
||||
report: without.clone(),
|
||||
expanded: true,
|
||||
dialog: 1,
|
||||
outcome: None,
|
||||
};
|
||||
let mut queue = DialogQueue::new();
|
||||
queue.push(
|
||||
DialogSource::UserAction,
|
||||
"t",
|
||||
"",
|
||||
DialogKind::Error {
|
||||
primary: "p".to_string(),
|
||||
hint: None,
|
||||
},
|
||||
);
|
||||
let request = queue.current().unwrap();
|
||||
let view = DialogRequestView::of(request);
|
||||
|
||||
let crash = current(lang, &view, Some(&with_card));
|
||||
let no_log = current(lang, &view, Some(&without_card));
|
||||
assert_ne!(crash.body_text(), no_log.body_text());
|
||||
assert!(crash.body_text().contains("写出了崩溃报告"));
|
||||
assert!(!no_log.body_text().contains("写出了崩溃报告"));
|
||||
// 没有 crash-report 的那一态仍然有可展开的细节(贴的是捕获行)与四条路径。
|
||||
assert!(no_log.card.as_ref().expect("卡片").detail_title.is_some());
|
||||
assert_eq!(path_rows(&lang.catalog(), &without).len(), 4);
|
||||
assert!(path_rows(&lang.catalog(), &without)
|
||||
.iter()
|
||||
.any(|(_, path)| path.ends_with("latest.log")));
|
||||
}
|
||||
|
||||
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
|
||||
#[test]
|
||||
fn closing_the_dialog_clears_the_card() {
|
||||
let mut app = headless();
|
||||
crash_report::publish(report(false, vec!["line".to_string()]));
|
||||
let finished = LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: "9f2c".to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 1_000,
|
||||
failure: None,
|
||||
};
|
||||
app.open_failure_dialog(&finished);
|
||||
assert!(app.state.dialogs.current().is_some(), "失败必须弹框");
|
||||
assert!(app.dialog_card.is_some());
|
||||
|
||||
app.close_dialog();
|
||||
assert!(app.state.dialogs.current().is_none());
|
||||
assert!(app.dialog_card.is_none());
|
||||
}
|
||||
|
||||
/// 成功收尾不打扰用户。
|
||||
#[test]
|
||||
fn a_successful_launch_does_not_pop_anything() {
|
||||
let mut app = headless();
|
||||
crash_report::publish(report(false, Vec::new()));
|
||||
let finished = LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: "9f2c".to_string(),
|
||||
outcome: LogOutcome::Ok,
|
||||
exit_code: Some(0),
|
||||
uptime_ms: 90_000,
|
||||
failure: None,
|
||||
};
|
||||
app.open_failure_dialog(&finished);
|
||||
assert!(app.state.dialogs.current().is_none());
|
||||
}
|
||||
|
||||
/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。
|
||||
#[test]
|
||||
fn export_writes_a_bundle_even_without_a_crash_report() {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("logs")).expect("建目录");
|
||||
std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志");
|
||||
|
||||
let mut spec_report = report(false, vec!["Error: boom".to_string()]);
|
||||
spec_report.launcher_logs = Some(root.join("logs"));
|
||||
spec_report.session_log = None;
|
||||
spec_report.run_dir = None;
|
||||
spec_report.version_json = None;
|
||||
|
||||
let text = super::export::export(&spec_report, Lang::ZhCn);
|
||||
assert!(text.contains("已导出"), "{text}");
|
||||
assert!(text.contains("字节"), "{text}");
|
||||
|
||||
let exports = root.join("logs").join("exports");
|
||||
let files: Vec<_> = std::fs::read_dir(&exports)
|
||||
.expect("导出目录")
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
assert_eq!(files.len(), 1, "一次导出只该落一个包");
|
||||
let size = files[0].metadata().expect("元数据").len();
|
||||
assert!(size > 0, "包不该是空文件");
|
||||
assert!(
|
||||
text.contains(&size.to_string()),
|
||||
"报出来的字节数要与磁盘一致:{text} / {size}"
|
||||
);
|
||||
}
|
||||
@@ -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