merge(dev): 解 M196 与 dev(M179/M193)在 settings/mod.rs 的冲突

`origin/main` 是 D41 的孤儿快照(单提交、与开发史无共同祖先),M179/M193 这些提交在
`origin/dev` 上;按「跟上主线」的意图合的是 `origin/dev`。用 merge 不用 rebase:复审 clean 的
tip `e5a4b9b` 原样保留,本提交只负责解冲突。

唯一冲突:`settings/mod.rs` 里 `form` 的文档注释(M179 与 M195 重写过同一段)。两边都留:
- **M179/M193**:`type Body` 带 `Lang`、四条按下标分发(General / Update / Assistant / Java)、
  M193 的 download / logs 接线及其文档更新——只有一处措辞随之调整:「四组在下面各有 `else if`」
  → 「在 [`section_columns`] 里各有 `else if`」,因为 M195 把那层循环抽成了独立函数;
- **M195**:循环之后不再 push 那张「没有逐实例覆盖」的空态卡、`overrides_empty` 不存在、
  `section_columns` 与其行为断言(`frame.rs` 两条用例)。
合并后的注释同时说明两件事:`GROUP.body` 从形参拿生效语言(M179)+ 尾部不许再挂东西、
逐实例覆盖的真控件在实例详情页(M195)。

为守住 400 行硬线(冲突态 424 → 399),只压自己的重复叙述:M196 段压到 4 行、
`section_columns` 的文档改为指向 `form`(它原来重复了一遍分发点,且在 M179 之后已不准确)、
模块文档里一段被 M179 实现取代的历史「r3 订正」压成一句可执行的结论。

代码零语义改动(证据见汇报):`section_columns` 的循环体与 `origin/dev` 归一化后逐字节相同(46 行),
`GROUPS` / `GROUP_LABELS` 三方一致;相对 `origin/dev` 的差异只剩本 mission 的 4 个文件。
This commit is contained in:
AskaEth committed 2026-11-09 05:18:29 +08:00
commit fb0b1a5311
274 files changed
+24717 -1998

No files matched your search

+6
View File
@@ -100,3 +100,9 @@ update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/updat
[lints]
workspace = true
# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。
# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回),
# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。
[build-dependencies]
winresource = "0.1"
+14
View File
@@ -0,0 +1,14 @@
//! `unlucky-launcher-iced` 的 PE 版本资源(M190)。
//!
//! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的
//! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。
//!
//! ⚠ 本文件**只**写版本资源,不碰子系统:PE 的 `Subsystem` 由 rustc 的
//! `windows_subsystem` 属性决定(本分支上还没有那个属性,是 M196 的事),版本资源与子系统是两件事。
//! 判据:`packaging/check_pe_version.py` 会把它读到的 `subsystem` 打出来,加资源前后必须一致。
include!("../../packaging/pe_version.rs");
fn main() {
emit_pe_version_resource("unlucky-launcher-iced");
}
+4 -3
View File
@@ -18,6 +18,7 @@
//! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。
//! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。
use crate::app::diag::diag;
use unlucky_presenter::dto::AppCommand;
use crate::pages::appearance::Change;
@@ -54,7 +55,7 @@ impl App {
} else {
line.push_str(" layered=skipped outcome=no-window");
}
eprintln!("{line}");
diag!("{line}");
}
/// 设置页改了一项外观。
@@ -123,8 +124,8 @@ impl App {
self.appearance_prefs = prefs;
match self.bridge.path() {
Some(path) => eprintln!("[settings] 外观已写回 {}", path.display()),
None => eprintln!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
Some(path) => diag!("[settings] 外观已写回 {}", path.display()),
None => diag!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
}
}
}
+11 -10
View File
@@ -12,6 +12,7 @@
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
use crate::app::diag::diag;
use std::time::{Duration, Instant};
use iced::Task;
@@ -51,10 +52,10 @@ pub(crate) fn run(options: Options) -> iced::Result {
let font_started = Instant::now();
let resolved = fonts::resolve();
let font_ms = font_started.elapsed();
eprintln!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
eprintln!("{}", resolved.describe());
diag!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
diag!("{}", resolved.describe());
if resolved.has_synthetic() {
eprintln!(
diag!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
);
}
@@ -64,7 +65,7 @@ pub(crate) fn run(options: Options) -> iced::Result {
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。
// **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
if options.screenshot.is_some() && options.shot_flavor().is_none() {
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
diag!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
}
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
diagnostics::report_theme(&options, flavor);
@@ -86,14 +87,14 @@ pub(crate) fn run(options: Options) -> iced::Result {
}
Some(OnFailure::Refuse) => {
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
eprintln!(
diag!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options
.screenshot
.as_ref()
.map_or_else(String::new, |path| path.display().to_string())
);
eprintln!(
diag!(
"[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。"
);
Err(iced::Error::GraphicsCreationFailed(error))
@@ -192,13 +193,13 @@ fn boot_probe(options: &Options) -> Boot {
if let Some(kind) = renderer::forced_fallback() {
let decision = renderer::fallback_round(kind);
std::env::set_var("ICED_BACKEND", kind.env_value());
eprintln!(
diag!(
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
renderer::FALLBACK_ENV,
kind.env_value(),
kind.env_value()
);
eprintln!("[probe] {}", decision.describe());
diag!("[probe] {}", decision.describe());
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
// ⇒ 日志写 `probe=skipped`,不写 0ms)。
let (probe, probe_ms) = if acceptance::enabled() {
@@ -232,11 +233,11 @@ fn boot_probe(options: &Options) -> Boot {
if cfg!(feature = "gpu") {
match probe_ms {
Some(elapsed) => eprintln!(
Some(elapsed) => diag!(
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
elapsed.as_secs_f64() * 1000.0
),
None => eprintln!(
None => diag!(
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
options.backend.name()
),
@@ -35,6 +35,11 @@ pub(super) fn window_settings(size: (f32, f32)) -> window::Settings {
size: iced::Size::new(size.0, size.1),
min_size: Some(MIN_SIZE),
// 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。
//
// M182:这一行**同时**是「macOS 四角是直角」的原因——winit 见到 `decorations: false`
// 会给出 `NSWindowStyleMask::Borderless`(没有 `Titled`),而窗口服务器只给 titled 窗
// 做圆角裁剪。链条与「要不要迁移原生标题栏」的口径写在 `platform::macos` 的模块文档里,
// 改动这一行之前先读那一页(迁移必须连同撤掉自绘三键一起做,否则两套控件打架)。
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
+4 -3
View File
@@ -9,6 +9,7 @@
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程
//! 只能建一次事件循环。
use crate::app::diag::diag;
use crate::renderer::Decision;
use crate::renderer::{self, acceptance, Kind};
@@ -58,10 +59,10 @@ pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2]
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let exe = std::env::current_exe().unwrap_or_else(|err| {
eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})");
diag!("[gpu] 降级重开失败:拿不到自身路径({err})");
std::process::exit(exit_code(None));
});
eprintln!(
diag!(
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
下一次的日志从 `[probe] 本次是降级重开` 开始。",
fallback.name(),
@@ -78,7 +79,7 @@ pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let code = match status {
Ok(status) => exit_code(status.code()),
Err(err) => {
eprintln!("[gpu] 降级重开失败:{err}");
diag!("[gpu] 降级重开失败:{err}");
exit_code(None)
}
};
+169
View File
@@ -0,0 +1,169 @@
//! 启动期诊断行的**双写**:stderr + `<.ULL>/logs/launcher.log`(M188)。
//!
//! ## 为什么要有这一层
//!
//! 双击启动的 GUI 进程**没有 stderr 去向**(Windows 上根本没有控制台)。此前 `[font]` /
//! `[probe]` / `[theme]` / `[appearance]` / `[gpu]` 这些诊断只走 `eprintln!`,于是「启动器视角
//! 到底发生了什么」在事故之后一个字都不剩——用户投诉的「只有一个安装日志」有一半是它。
//!
//! 现在这些行同时进 `launch::launcher_log`:
//!
//! ```text
//! diag!("[font] 求值耗时 {:.0} ms", ms) → stderr: [font] 求值耗时 12 ms
//! → launcher.log: 2026-… [font] 求值耗时 12 ms
//! ```
//!
//! scope 就是行首那个 `[tag]`,所以日志行与老用法**长得一样**,`grep '\[gpu\]'` 仍然好使。
//!
//! ## 哪些行进日志、哪些只进 stderr
//!
//! 判据(M188 复审后收紧成这一句):**逐帧度量不接;显式取证开关与一次性事故行可接。**
//!
//! | 进日志 | 只进 stderr |
//! |---|---|
//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | **验收与出图矩阵的逐帧度量**:`[window]` / `[perf]` / `[caps]` / `[theme] source=`(`renderer::acceptance/**`,一次启动上百行) |
//! | **一次性事故行**:`[gpu] <backend> 初始化失败(具名)` / `[gpu] 失败详情(具名)`(`renderer::report_failure`)、`[gpu] iced_wgpu: …Selected: AdapterInfo…`(`log_bridge`)、`[probe] request=… -> ICED_BACKEND=…`(`probe::apply_with_preflight`)、`[gpu-preflight]`(`ULL_PREFLIGHT=1`) | 组件几何探针的断言输出(`components/**` 的 `[feedback-probe]` / gallery 页面里的 `[gallery]`) |
//! | 用户动作的结果(打开目录、写设置失败、更新忽略/应用、语言没落盘) | 每条日志行的正文里再套一层括号(不是标签,见 [`split_scope`]) |
//! | 显式开关下的一次性行:`[tick]`(`ULL_TICK_TRACE=1`,≈1 行/秒 ≈ 4.8 小时才滚一次)、`[shot]` 的通道提示 | — |
//!
//! 为什么逐帧行不许接:`launcher.log` 有 1 MiB 上限(滚成 `launcher.1.log`,只留一份),
//! 逐帧写会在几分钟内把「事故现场」冲掉。反过来,`report_failure` 那两行**就是**事故本体——
//! 双击启动没有 stderr,把它们留在 stderr 等于在最该有日志的路径上什么都没有(M188 复审 P2-1)。
/// 行首没有 `[tag]` 时的默认 scope。
pub const APP_SCOPE: &str = "app";
/// 记一行诊断:stderr 照旧,另外进启动器日志(best-effort,写不进去也不影响启动)。
pub(crate) fn emit(line: &str) {
eprintln!("{line}");
let (scope, message) = split_scope(line);
unlucky_core::launch::launcher_log::record(scope, message);
}
/// `[font] 求值耗时 12 ms` → (`font`, `求值耗时 12 ms`)。
///
/// scope 只认「短、无空白、非空」的方括号前缀:`[a b]` 这种是正文而不是标签,别把整行拆坏。
pub(crate) fn split_scope(line: &str) -> (&str, &str) {
let Some(rest) = line.strip_prefix('[') else {
return (APP_SCOPE, line);
};
let Some((scope, tail)) = rest.split_once(']') else {
return (APP_SCOPE, line);
};
if scope.is_empty() || scope.len() > 24 || scope.contains(char::is_whitespace) {
return (APP_SCOPE, line);
}
(scope, tail.trim_start())
}
/// 双写一行诊断(`diag!("[scope] 正文")`,参数同 `eprintln!`)。
macro_rules! diag {
($($arg:tt)*) => {{
crate::app::diag::emit(&format!($($arg)*));
}};
}
pub(crate) use diag;
/// 串行化「会改 `launcher_log` 全局目录」的用例。
///
/// `core::launch::launcher_log` 是**进程级单例**(目录 + 缓冲),而 app-iced 的测试二进制里
/// 有两条用例会 `set_dir` 到自己的临时目录(本文件的滚动闸、`app/dialog/tests.rs` 的启动事实闸)。
/// 它们必须串行,否则互相把对方的落点改走——那是两条闸互相打架,不是产品问题。
#[cfg(test)]
pub(crate) static LOG_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::split_scope;
#[test]
fn the_leading_bracket_becomes_the_scope() {
assert_eq!(
split_scope("[font] 求值耗时 12 ms"),
("font", "求值耗时 12 ms")
);
assert_eq!(split_scope("[gpu]"), ("gpu", ""));
// 正文里的方括号不是标签
assert_eq!(
split_scope("[page] 占位动作 [x] pressed"),
("page", "占位动作 [x] pressed")
);
}
#[test]
fn lines_without_a_tag_fall_back_to_the_default_scope() {
assert_eq!(split_scope("没有标签的一行"), ("app", "没有标签的一行"));
assert_eq!(
split_scope("[a b] 空白不是标签"),
("app", "[a b] 空白不是标签")
);
}
/// 守门②:**启动器日志的滚动真的会发生**,写超上限之后文件仍然有界。
///
/// ## 为什么要「重试」
///
/// `launcher_log` 是**进程级单例**(目录 + 缓冲),而同进程里还有一条用例
/// (`app/dialog/tests.rs` 的 `boot_facts_are_recorded_into_the_launcher_log`)会
/// `set_dir` 到它自己的临时目录。两边撞上时我这几行的落点就换了人,所以每一轮都要
/// **从头跑一遍**(登记 → 造超限 → 追加 → 看有没有滚),并且用「我这份超限文件有没有被
/// rename」判断这一轮是不是真的跑在我目录上。重试不是放宽断言:每轮该发生的都得真发生。
///
/// 变异证明:删掉 `core::launch::launcher_log::append` 里那段「超限就 rename 成 launcher.1.log」,
/// 每一轮都不会出现 `launcher.1.log`,本条在重试用尽后红。
#[test]
fn the_launcher_log_rolls_over_and_stays_bounded() {
use unlucky_core::launch::launcher_log;
let _guard = super::LOG_STATE_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let dir = std::env::temp_dir().join(format!("ull-launcher-log-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建夹具目录");
let path = dir.join(launcher_log::FILE_NAME);
let rolled = dir.join("launcher.1.log");
let oversize = usize::try_from(launcher_log::MAX_BYTES).expect("上限能塞进 usize") + 16;
for _ in 0..50 {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&rolled);
launcher_log::set_dir(&dir);
if launcher_log::path() != Some(path.clone()) {
continue; // 这一轮被别人的 set_dir 抢先了
}
launcher_log::record("boot", "第一行");
std::fs::write(&path, vec![b'x'; oversize]).expect("造一份超限日志");
if launcher_log::path() != Some(path.clone()) {
continue;
}
launcher_log::record("boot", "滚过之后这一行必须在新文件里");
if launcher_log::path() != Some(path.clone()) {
continue; // 追加落到了别人的目录,这一轮不算
}
// 到这儿为止,追加**确实**落在本目录:超限就必须滚。
assert!(rolled.is_file(), "超限之后旧档必须滚成 launcher.1.log");
let size = std::fs::metadata(&path).expect("新文件").len();
assert!(
size < launcher_log::MAX_BYTES,
"滚完之后新文件必须远小于上限:{size}"
);
let text = std::fs::read_to_string(&path).expect("读新文件");
assert!(text.contains("滚过之后这一行"), "{text}");
// 只留一份历史:再滚一次仍然是 `launcher.1.log`,不会长出 launcher.2.log。
std::fs::write(&path, vec![b'y'; oversize]).expect("再造一份超限日志");
launcher_log::record("boot", "第二次滚动");
assert!(rolled.is_file());
assert!(!dir.join("launcher.2.log").exists(), "历史只留一份");
assert!(std::fs::metadata(&path).expect("新文件").len() < launcher_log::MAX_BYTES);
std::fs::remove_dir_all(&dir).ok();
return;
}
panic!("50 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来");
}
}
+390
View File
@@ -0,0 +1,390 @@
//! 模态对话框(M187 接通 `DialogQueue`)+ 启动失败卡片。
//!
//! ## 为什么先补这一层
//!
//! 接线审计(M177)的结论很硬:`DialogQueue` / `DialogRequest` 在 app-iced 里**零引用**,
//! 所有弹窗都画不出来。于是「像 PCL2 / HMCL 那样,启动失败时弹一个带崩溃说明的框」这件事
//! 在此之前根本无处落脚。本模块是最小可用的一层:
//!
//! ```text
//! presenter AppState::dialogs(唯一队列)
//! │ current()
//! ▼
//! dialog::current(lang, request, card) ← 纯视图模型(测试打这里)
//! │
//! ▼
//! dialog::view::layer(ui, base, modal) ← 模态遮罩 + 卡片 + 主/次按钮
//! ```
//!
//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。
//!
//! ## 崩溃卡片额外带什么
//!
//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**:
//! 退出码、一句人话原因、**文件路径**、可展开的崩溃说明、以及三个按钮(导出 / 打开文件夹 /
//! 复制路径)。这些东西不进 `DialogRequest`——它是 presenter 的公共 DTO,塞进 UI 专用字段
//! 会把「谁拥有证据」搅浑。做法是:队列管**弹窗**,[`CrashCard`] 管**这次失败的事实**,
//! 两者按 `DialogId` 绑定,见 [`App::open_failure_dialog`]。
//!
//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它,
//! 不重算:**界面不读盘、不猜原因**。
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
use unlucky_i18n::catalog::{Catalog, Key};
use unlucky_i18n::Lang;
use unlucky_presenter::dialogs::{DialogId, DialogKind, DialogSource};
use crate::app::state::App;
pub(super) mod export;
#[cfg(test)]
mod tests;
pub(super) mod view;
/// 崩溃卡片:一次失败的**事实**(不含任何结论)。
#[derive(Debug, Clone)]
pub struct CrashCard {
/// 现场。
pub report: CrashReport,
/// 「详细崩溃说明」是否展开。
pub expanded: bool,
/// 与哪一条队列请求绑定(关掉它时把卡片一起清掉)。
pub dialog: DialogId,
/// 上一次动作的结论(导出到哪 / 打不开文件夹的原因)。
///
/// 为什么写在卡片里而不是只推 Toast:app-iced 的 Toast 通道**至今没有任何渲染点**
/// (`state.toasts` 只被推入与过期,`view` 里一次都没读)。导出这种「必须看见落到哪」的
/// 结果不能挂在一条不显示的通道上。
pub outcome: Option<String>,
}
/// 弹窗消息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
/// 主按钮。
Primary,
/// 次按钮。
Secondary,
/// 点遮罩 / Esc。
Dismiss,
/// 展开 / 收起详细说明。
Toggle,
/// 打开日志文件夹。
OpenFolder,
/// 复制全部路径。
CopyPaths,
/// 复制第 `index` 条路径(`Modal::paths` 的下标)。
CopyPath(usize),
}
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct Modal {
/// 标题。
pub title: String,
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
pub body: Vec<String>,
/// 主按钮文字。
pub primary: String,
/// 次按钮文字(`None` = 不画)。
pub secondary: Option<String>,
/// 是否有次按钮(`Progress` 没有)。
pub blocking_progress: bool,
/// 崩溃卡片(普通弹窗为 `None`)。
pub card: Option<CardView>,
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
pub notice: Option<String>,
}
/// 崩溃卡片的展示内容。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct CardView {
/// 展开 / 收起按钮的文字。
pub toggle: String,
/// 当前的展开态。
pub expanded: bool,
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
pub paths: Vec<(String, String)>,
/// 详细说明的小标题(`None` = 没有可展开的内容)。
pub detail_title: Option<String>,
/// 详细说明的正文行。
pub detail_lines: Vec<String>,
/// 三个按钮的文字。
pub actions: [String; 3],
}
impl Modal {
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
#[must_use]
#[cfg(test)]
pub fn body_text(&self) -> String {
self.body.join("\n")
}
}
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
#[must_use]
pub(super) fn current(
lang: Lang,
request: &DialogRequestView<'_>,
card: Option<&CrashCard>,
) -> Modal {
let catalog = Catalog::of(lang);
let mut modal = Modal {
title: request.title.to_string(),
body: vec![request.body.to_string()],
primary: request.primary.to_string(),
secondary: request.secondary.map(str::to_string),
blocking_progress: request.blocking_progress,
card: None,
notice: None,
};
if let Some(card) = card {
let detail = detail_of(&catalog, card);
modal.body = failure_body(&catalog, &card.report);
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
modal.card = Some(CardView {
toggle: catalog
.text(if card.expanded {
Key::CrashDialogHideDetail
} else {
Key::CrashDialogShowDetail
})
.to_string(),
expanded: card.expanded,
paths: path_rows(&catalog, &card.report),
detail_title: detail.0,
detail_lines: detail.1,
actions: [
catalog.text(Key::CrashDialogExport).to_string(),
catalog.text(Key::CrashDialogOpenFolder).to_string(),
catalog.text(Key::CrashDialogCopyPaths).to_string(),
],
});
modal.notice = card.outcome.clone();
}
modal
}
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
pub(super) struct DialogRequestView<'a> {
/// 标题。
pub title: &'a str,
/// 正文。
pub body: &'a str,
/// 主按钮。
pub primary: &'a str,
/// 次按钮。
pub secondary: Option<&'a str>,
/// 进度框不可点主按钮。
pub blocking_progress: bool,
}
impl<'a> DialogRequestView<'a> {
/// 从 presenter 的请求取视图。
pub(super) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
Self {
title: &request.title,
body: &request.body,
primary: request.kind.primary_label(),
secondary: request.kind.secondary_label(),
blocking_progress: request.kind.is_blocking_progress(),
}
}
}
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
///
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
let mut body = Vec::new();
if let Some(code) = report.exit_code {
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
}
if let Some(stage) = &report.stage {
let label = stage_label(catalog, stage);
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
} else if report.exit_code == Some(0) {
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
} else {
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
}
body.push(
match report.evidence() {
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
}
.to_string(),
);
body
}
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
let key = match slug {
"version_manifest" => Key::LaunchStageVersionManifest,
"inheritance" => Key::LaunchStageInheritance,
"instance_config" => Key::LaunchStageInstanceConfig,
"isolation" => Key::LaunchStageIsolation,
"identity" => Key::LaunchStageIdentity,
_ => Key::LaunchStageSpawn,
};
catalog.text(key)
}
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
let mut rows = Vec::new();
if let Some(log) = &report.session_log {
rows.push((
catalog.text(Key::CrashDialogSessionLog).to_string(),
log.display().to_string(),
));
}
if let Some(dir) = &report.launcher_logs {
rows.push((
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
dir.display().to_string(),
));
}
if let Some(path) = report.latest_log_path() {
rows.push((
catalog.text(Key::CrashDialogGameLog).to_string(),
path.display().to_string(),
));
}
if let Some(dir) = report.crash_reports_dir() {
rows.push((
catalog.text(Key::CrashDialogCrashReports).to_string(),
dir.display().to_string(),
));
}
rows
}
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
let report = &card.report;
if let Some(artifact) = report
.artifacts
.iter()
.find(|artifact| artifact.source == "crash_report")
{
let mut lines = Vec::new();
if let Some(excerpt) = &artifact.excerpt {
lines.extend(excerpt.lines().map(str::to_string));
} else {
lines.push(artifact.path.display().to_string());
}
let title = format!(
"{} {}",
catalog.text(Key::LogsDiagnosisTitle),
artifact.path.display()
);
return (Some(title), lines);
}
if report.stderr_tail.is_empty() {
return (None, Vec::new());
}
let title = catalog.render(
Key::CrashDialogCapturedTail,
&[("count", &report.stderr_tail.len().to_string())],
);
(Some(title), report.stderr_tail.clone())
}
// --------------------------------------------------------------- 与 App 的接线
impl App {
/// 队列当前条 → 视图模型(`view()` 与测试共用同一个入口)。
pub(super) fn dialog_modal(&self) -> Option<Modal> {
let request = self.state.dialogs.current()?;
let view = DialogRequestView::of(request);
let card = self
.dialog_card
.as_ref()
.filter(|card| card.dialog == request.id);
Some(current(self.lang(), &view, card))
}
/// 收到一次启动收尾:失败就弹框(成功不打扰)。
///
/// 只在**真的失败**时弹:`LogOutcome::Ok` 且没有阶段失败 ⇒ 什么都不做,与既有 Toast 口径一致。
pub(super) fn open_failure_dialog(
&mut self,
finished: &unlucky_presenter::dto::LaunchFinished,
) {
let failed = finished.outcome != unlucky_presenter::dto::LogOutcome::Ok
|| finished.failure.is_some();
if !failed {
return;
}
let Some(report) = unlucky_core::launch::crash_report::failing_launch() else {
return;
};
if !finished.session.is_empty() && report.session != finished.session {
// 登记的是另一场(并发启动):不拿它去解释这一场。
return;
}
let catalog = self.lang().catalog();
let crashed = report.started();
let title = catalog.text(if crashed {
Key::CrashDialogTitleCrashed
} else {
Key::CrashDialogTitleFailed
});
let summary = self.dialog_summary(&report);
let at = self.lang();
let id = self.state.dialogs.push(
DialogSource::UserAction,
title,
summary,
DialogKind::Confirm {
primary: Catalog::of(at).text(Key::CrashDialogExport).to_string(),
secondary: Catalog::of(at).text(Key::CrashDialogClose).to_string(),
},
);
self.dialog_card = Some(CrashCard {
report,
// 默认展开:正文已经说「下面是捕获的最后若干行」,收着就成了空口承诺。
expanded: true,
dialog: id,
outcome: None,
});
self.view_dirty = true;
}
/// 队列标题下的那一行摘要(弹窗正文由 [`current`] 按卡片重算,这里给它一份独立可读的短句)。
fn dialog_summary(&self, report: &CrashReport) -> String {
let catalog = self.lang().catalog();
match report.exit_code {
Some(code) => catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]),
None => catalog.text(Key::CrashDialogTitleFailed).to_string(),
}
}
/// 关掉当前弹窗(主/次按钮、遮罩、`Esc` 共用)。
pub(super) fn close_dialog(&mut self) {
let closed = self.state.dialogs.current().map(|request| request.id);
self.state.dialogs.close_current();
if self
.dialog_card
.as_ref()
.is_some_and(|card| closed == Some(card.dialog))
{
self.dialog_card = None;
}
self.view_dirty = true;
}
/// 展开 / 收起详细说明。
pub(super) fn toggle_dialog_detail(&mut self) {
if let Some(card) = &mut self.dialog_card {
card.expanded = !card.expanded;
self.view_dirty = true;
}
}
}
+205
View File
@@ -0,0 +1,205 @@
//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。
//!
//! 与 [`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;
/// 一次导出的结论(**成功也带事实**:路径、字节数、有没有文件被截断)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Exported {
/// 给用户看的那一句(已本地化)。
pub text: String,
/// 是否成功。
pub ok: bool,
/// 有几个文件被截断(`>0` 时界面要明说「证据是残的」)。
pub truncated: usize,
}
/// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。
///
/// 返回的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」);
/// 有文件被截断时再补一句——别让用户以为包里的证据是完整的。
pub(crate) fn export(report: &CrashReport, lang: Lang) -> Exported {
let catalog = Catalog::of(lang);
let Some(logs) = &report.launcher_logs else {
return Exported {
text: catalog.render(
Key::CrashDialogExportFailed,
&[("error", "no launcher log dir")],
),
ok: false,
truncated: 0,
};
};
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) => {
let mut text = catalog.render(
Key::CrashDialogExportDone,
&[
("bytes", &bundle.bytes.to_string()),
("path", &bundle.path.display().to_string()),
],
);
if bundle.truncated > 0 {
text.push('\n');
text.push_str(&catalog.render(
Key::CrashDialogExportTruncated,
&[("count", &bundle.truncated.to_string())],
));
}
Exported {
text,
ok: true,
truncated: bundle.truncated,
}
}
Err(error) => Exported {
text: catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]),
ok: false,
truncated: 0,
},
}
}
/// 现场 → 诊断包清单(**进包的东西必须与弹窗上列出的路径一致**)。
fn spec_of(report: &CrashReport) -> BundleSpec {
let mut entries: Vec<BundleEntry> = Vec::new();
let push = |entries: &mut Vec<BundleEntry>, group: &'static str, path: &std::path::Path| {
if path.exists() {
entries.push(BundleEntry::new(group, path));
}
};
if let Some(logs) = &report.launcher_logs {
push(&mut entries, "launcher", &logs.join("launcher.log"));
push(&mut entries, "launcher", &logs.join("launcher.1.log"));
push(&mut entries, "launcher", &logs.join("install.log"));
}
if let Some(session) = &report.session_log {
// 源文件名就是会话 id:钉成 `session.jsonl`,包内名与清单名同时不再泄漏 id,
// 也不会出现「清单写 <redacted:token> 而包里是原始 id」(r1 复审 P2-2)。
if session.exists() {
entries.push(BundleEntry::named("session", session, "session.jsonl"));
}
}
for artifact in &report.artifacts {
// `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。
if artifact.source != "latest_log" {
push(&mut entries, "game", &artifact.path);
}
}
if let Some(latest) = report.latest_log_path() {
push(&mut entries, "game", &latest);
}
if let Some(json) = &report.version_json {
push(&mut entries, "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 open_dir(&dir) {
Ok(()) => String::new(),
Err(error) => catalog.render(Key::CrashDialogOpenFailed, &[("error", &error)]),
}
}
/// 打开一个目录(M188:设置页/日志页的「打开日志文件夹」也走这里,**不再另写一套 `cfg`**)。
///
/// 失败文本带完整路径:调用方把它显示/记日志出来,用户至少能照着路径去找。
pub(crate) fn open_dir(dir: &std::path::Path) -> Result<(), String> {
if !dir.is_dir() {
return Err(format!("{}(目录不存在)", dir.display()));
}
spawn_opener(dir).map_err(|error| format!("{}(打开失败:{error})", dir.display()))
}
#[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")
}
+344
View File
@@ -0,0 +1,344 @@
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
//!
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
//!
//! | 用例 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`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());
}
/// P2-1:**卡片那一层只能是卡片大**。
///
/// 抓得住什么:r1 复审实测的那个 bug —— `mouse_area(整窗居中容器)` 的命中区是整窗,
/// 卡片外任何一次点击都先被它捕获,遮罩永远收不到(「点遮罩关闭」形同虚设)。
/// `Widget::size_hint()` 不需要渲染器就能问,所以这条能在单测里跑:卡片层若声明
/// `Fill`/`Fill`,就等于「命中区 = 整窗」。
///
/// **抓不住什么**:端到端指针派发(本仓没有事件 harness,`Element` 的 `layout`/`update`
/// 需要真渲染器)。所以这条守的是**结构属性**,不是「点一下真的关了」。
#[test]
fn the_card_layer_is_card_sized_so_the_backdrop_can_be_clicked() {
use iced::Length;
let mut app = headless();
app.state.dialogs.push(
DialogSource::UserAction,
"t",
"b",
DialogKind::Confirm {
primary: "p".to_string(),
secondary: "s".to_string(),
},
);
let modal = {
let request = app.state.dialogs.current().expect("当前条");
let view = DialogRequestView::of(request);
super::current(app.lang(), &view, None)
};
let plane = super::view::card_plane(app.ui(), &modal);
let hint = iced::advanced::Widget::size_hint(plane.as_widget());
// 任一维是 `Fill` 就意味着「跟随父级 = 整窗」(外层是 Fill 的居中容器),
// 命中区于是变成整窗 —— 那正是 r1 抓到的原实现。
assert_ne!(
hint.width,
Length::Fill,
"卡片层宽度不能 Fill(会撑成整窗)"
);
assert_ne!(
hint.height,
Length::Fill,
"卡片层高度不能 Fill(会撑成整窗)"
);
}
/// P1-1:遮罩必须是**不透明预合成**,而且两档都**压暗**(深色档尤其)。
///
/// 抓得住 r1 实测的那个 p1:`Role::OnSurface @ 32%` 在深色档是「近白 @32%」⇒ 遮罩把整窗
/// 提亮(实测 (9,21,28) → (129,138,145))。这里用亮度直接判方向:遮罩必须比画布暗。
///
/// 抓不住:具体色值对不对(那是令牌层的事)。
#[test]
fn the_backdrop_is_an_opaque_precomposite_and_darkens_both_flavors() {
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
let luma = |color: iced::Color| 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES);
// 断言**视图真正用的那一份**(不是组件层的默认值):改回「OnSurface @32%」这里就红。
let backdrop = super::view::backdrop_color(&ui);
assert_eq!(
backdrop.a, 1.0,
"{flavor:?}:遮罩必须是不透明预合成(D27:CPU 档零半透明)"
);
let canvas = crate::theme::to_iced(
unlucky_design::color::Scheme::baseline(flavor)
.get(MaterialLayer::Canvas.surface_role()),
);
assert!(
luma(backdrop) < luma(canvas),
"{flavor:?}:遮罩必须比画布暗(当前 lane {:.3} vs 画布 {:.3})—— 用 Role::OnSurface 当遮罩会在深色档把整窗提亮",
luma(backdrop),
luma(canvas)
);
assert_eq!(Role::Surface, MaterialLayer::Canvas.surface_role());
}
}
/// P2-4:启动那一拍的**事实**确实进了启动器日志(登记目录后一次刷出)。
///
/// 抓得住:记录函数被拆掉/改空(缓冲里就什么都没有)。
/// 抓不住:`on_boot` 有没有调它 —— 那靠这条用例旁边的调用点与 `host/launch.rs` 的 `set_dir`,
/// 真机第一次崩溃才是最终验收闸门(复审与我的一致口径)。
#[test]
fn boot_facts_are_recorded_into_the_launcher_log() {
// 与 `app/diag.rs` 的滚动闸串行:两条用例都动同一个进程级单例目录。
let _guard = crate::app::diag::LOG_STATE_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let dir = std::env::temp_dir().join(format!("ull-m187-bootlog-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建目录");
unlucky_core::launch::launcher_log::set_dir(&dir);
let app = headless();
crate::app::update::boot_env::record_boot_log(&app);
let path = dir.join(unlucky_core::launch::launcher_log::FILE_NAME);
let text = std::fs::read_to_string(&path).expect("launcher.log");
assert!(text.contains("[boot] app boot"), "{text}");
assert!(text.contains("theme="), "{text}");
assert!(text.contains("window="), "{text}");
}
#[path = "tests/export.rs"]
mod export;
@@ -0,0 +1,85 @@
//! 导出的用例:真的落盘、真的报字节数、真的在有截断时说一句。
//!
//! 从 `tests.rs` 拆出(那个文件贴着 400 行硬线)。共用父模块的 `report()` 夹具。
use unlucky_i18n::Lang;
use super::report;
use crate::app::dialog::export;
/// 导出:真的落盘、真的报出路径与字节数(**没有 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 done = export::export(&spec_report, Lang::ZhCn);
let text = done.text.clone();
assert!(done.ok, "{text}");
assert_eq!(done.truncated, 0);
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}"
);
}
/// P3-4:`truncated > 0` 时**界面上那一句**真的出现(r2 复审登记的那条没有用例)。
///
/// 抓得住:把 `Exported.text` 里「有文件被截断」那句删掉/短路(变异证明见报告)。
/// 抓不住:那句话排版好不好看(视觉)。
#[test]
fn a_truncated_export_says_so_in_the_notice() {
use unlucky_core::launch::crash_bundle::MAX_ENTRY_BYTES;
let root = std::env::temp_dir().join(format!("ull-m187-trunc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("logs")).expect("建目录");
std::fs::create_dir_all(root.join("game").join("logs")).expect("建目录");
std::fs::write(root.join("logs").join("launcher.log"), "boot\n").expect("写日志");
// 略超上限的 latest.log:必须用**真实日志行**填充 —— 同一字符的 4 MiB 会被
// 「长不透明串」脱敏规则整段替换成占位符,长度就不超限了(那测的是脱敏不是截断)。
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk\n";
let mut big = String::new();
while big.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 256 {
big.push_str(line);
}
std::fs::write(root.join("game").join("logs").join("latest.log"), &big).expect("写大日志");
let mut report = report(false, Vec::new());
report.launcher_logs = Some(root.join("logs"));
report.run_dir = Some(root.join("game"));
report.session_log = None;
report.version_json = None;
let done = export::export(&report, Lang::ZhCn);
assert!(done.ok, "{}", done.text);
assert_eq!(
done.truncated, 1,
"这份 latest.log 必须真的被截断(>4 MiB)"
);
assert!(
done.text.contains("已截断"),
"界面上没有说「有文件被截断」:{}",
done.text
);
}
+299
View File
@@ -0,0 +1,299 @@
//! 模态层的控件树:**遮罩 + 卡片 + 主/次按钮**,正文与路径全部来自 [`super::Modal`]。
//!
//! ## 命中语义(为什么点遮罩能关、点卡片不关)
//!
//! `iced::widget::Stack` 的 `update` 从上往下(后画的先收)派发,遇到 `shell.is_event_captured()`
//! 立刻停。所以三层的顺序是:
//!
//! ```text
//! stack![ 页面树, 遮罩(mouse_area→Dismiss,整窗), 居中容器(透明)→ opaque(卡片) ]
//! ```
//!
//! 卡片那层用 `opaque` 把卡片内的左键按下**吃掉**([`super::Message`] 不需要再有一条专用
//! 消息),于是点卡片里的空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获。
//! **`opaque` 只包卡片**是这条语义成立的前提:包住整窗居中容器的话,命中区就变成整窗,
//! 遮罩永远收不到按下(r1 复审 P2-1 抓到的原实现)。
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::app::components::feedback::dialog::scrim;
use crate::app::components::feedback::panel::DialogGeometry;
use crate::pages::frame;
use crate::theme::Ui;
use super::super::message::Message;
use super::{Message as DialogMessage, Modal};
/// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。
const SCROLL_HEIGHT: f32 = 300.0;
/// 有导出结论时滚动区让出的高度(那一段会多占两行,不让就只能压扁页脚按钮)。
const SCROLL_HEIGHT_WITH_NOTICE: f32 = 210.0;
/// 卡片最大高(兜底:不许把边框撑出屏幕)。
const CARD_HEIGHT: f32 = 660.0;
/// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。
///
/// ## 三层的命中语义(r1 复审 P2-1 的修法)
///
/// ```text
/// stack![ 页面树, 遮罩(mouse_area→Dismiss, 整窗), 居中容器(透明) → opaque(卡片) ]
/// ```
///
/// 关键在于**卡片那层只有卡片那么大**:`opaque(卡片)` 的命中区就是卡片本身,点卡片里
/// 只会被它吃掉(`shell.capture_event`),点卡片外则**继续往下走**到遮罩 → `Dismiss`。
/// 反例(r1 抓到的原实现):`mouse_area(整窗居中容器).on_press(Swallow)` —— 整窗都在它
/// 的 bounds 里,任何一次卡片外点击都先被它捕获,遮罩永远收不到,于是「点遮罩关闭」
/// 只存在于注释里。
pub(crate) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
modal: &'a Modal,
) -> Element<'a, Message> {
let backdrop = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(backdrop_color(ui))),
..container::Style::default()
}),
)
.on_press(Message::Dialog(DialogMessage::Dismiss));
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
let centered = container(card_plane(ui, modal))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, backdrop, centered].into()
}
/// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。
///
/// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把
/// 整窗**提亮**(r1 复审实测 (9,21,28) → (129,138,145));而且 α 混色在 wgpu/tiny-skia 两档
/// 合成口径不同(linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。
///
/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。
pub(super) fn backdrop_color(ui: &Ui) -> Color {
scrim(ui)
}
/// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。
///
/// 可见性放宽到 `pub(super)` 只为让 [`super::tests`] 能断言它的 `size_hint`(P2-1 的守卫)。
pub(super) fn card_plane<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
iced::widget::opaque(card(ui, modal))
}
/// 卡片本体(**尺寸 = 内容**,不声明 `Fill` —— 见 [`layer`] 的命中语义)。
fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
// 几何全部走组件层(r1 复审 P1 的纪律项:别绕过现成的 Dialog 档)。
let geometry = DialogGeometry::of(ui);
let shape = Component::Dialog.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(if modal.notice.is_some() {
SCROLL_HEIGHT_WITH_NOTICE
} else {
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);
// 宽度取官方**上限** 560(`DialogGeometry::max_width`)而不是本项目默认 480:
// 崩溃卡片多两行路径 + 三枚辅助按钮,480 会把主按钮压成竖排(实测过)。
// 边界:组件层日后若改宽度,这里不会跟(已记 P3 台账)。
container(body)
.width(Length::Fixed(geometry.max_width))
.max_height(CARD_HEIGHT)
.padding(geometry.padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: ui.outlined(shape),
..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()
}
+121
View File
@@ -0,0 +1,121 @@
//! 「日志文件夹」在 app 侧的唯一解析口(M188):路径从哪来、点了按钮之后开哪个目录。
//!
//! ## 为什么不在页面里现拼路径
//!
//! 日志根 = `<启动器数据目录>/logs`,而「启动器数据目录」是三模式解析(便携 / 标准 / 自定义)
//! 的结果。那条解析链只有一份实现(`core::platform::data_dir` + `presenter::settings_bridge`),
//! 页面若自己拼 `exe_dir/.ULL/logs`,标准模式与自定义模式的用户就会看到一个**不存在的目录**。
//!
//! 本模块只做两件事:
//!
//! 1. 从设置桥给定的 `config/launcher.json` 反推 `<home>`,再用 `LauncherLayout::logs()` 取分区
//! (分区名只从 core 的 `layout` 来,不手拼字符串);
//! 2. 打开前先确认目录真的在(不在就建),失败时给出的文本**带完整路径**——用户至少能照着去翻。
//!
//! ## 谁来登记
//!
//! `App::new` 拿到设置桥之后调 [`register`]:那是本进程唯一知道「实际生效的数据根」的地方
//! (`--data-dir` 也在这里生效)。没登记时(`pages_demo` 出图通道、单测)退回
//! `platform::data::layout()`——同一份解析链的缓存版本。
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_presenter::settings_bridge::SettingsBridge;
static LOGS_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
/// `<home>/config/launcher.json` → `<home>/logs`(**只有**这一条形状,别处不许手拼)。
pub(crate) fn logs_dir_of(config: &Path) -> Option<PathBuf> {
let home = config.parent()?.parent()?;
Some(LauncherLayout::new(home).logs())
}
/// 登记本进程的日志目录(`App::new` 在第一帧之前调一次;重复调用以**第一次**为准)。
pub(crate) fn register(bridge: &SettingsBridge) {
let _ = LOGS_DIR.set(bridge.path().and_then(logs_dir_of));
}
/// 本进程的日志目录。
///
/// - 已登记且解析出来 ⇒ 那条;
/// - 已登记但解析不出来(内存态桥)⇒ `None`:页面照实说「数据根没解析出来」,不猜一个目录;
/// - 还没登记(`pages_demo` / 单测)⇒ 退回 `platform::data::layout()`。
pub(crate) fn logs_dir() -> Option<PathBuf> {
match LOGS_DIR.get() {
Some(known) => known.clone(),
None => crate::platform::data::layout().map(|layout| layout.logs()),
}
}
/// 确保日志目录存在(不在就建),返回给用户看的路径。
///
/// 失败文本里**带完整路径**:界面把这段话原样显示出来,用户能复制去自己找。
pub(crate) fn prepare(dir: &Path) -> Result<PathBuf, String> {
std::fs::create_dir_all(dir)
.map_err(|error| format!("{}(这个目录建不出来:{error})", dir.display()))?;
if !dir.is_dir() {
return Err(format!("{}(这个路径不是一个目录)", dir.display()));
}
Ok(dir.to_path_buf())
}
/// 打开日志文件夹:解析 → 确保存在 → 交给平台打开器。
///
/// # Errors
///
/// 返回的文本永远带路径(`...logs` 那一串),可以直接贴给用户/贴进 issue。
pub(crate) fn open() -> Result<PathBuf, String> {
let dir = logs_dir().ok_or_else(|| {
"日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)"
.to_string()
})?;
let dir = prepare(&dir)?;
crate::app::dialog::export::open_dir(&dir)?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
/// 落点形状:`<home>/config/launcher.json` → `<home>/logs`。
///
/// 变异证明:把 `logs_dir_of` 里的 `LauncherLayout::logs` 换成 `cache`,这条立刻红。
#[test]
fn the_log_folder_is_the_logs_partition_next_to_config() {
let config = PathBuf::from("/tmp/ull-home/.ULL/config/launcher.json");
assert_eq!(
logs_dir_of(&config),
Some(PathBuf::from("/tmp/ull-home/.ULL/logs"))
);
// 少了 config 那一层就没有 `<home>` 可言:宁可不猜。
assert_eq!(logs_dir_of(Path::new("launcher.json")), None);
}
/// **目录真的存在**(守门③):`prepare` 在不在时把它建出来,并且返回同一个路径。
///
/// 变异证明:删掉 `create_dir_all` 那一行,`is_dir` 立刻为假。
#[test]
fn prepare_creates_the_folder_and_reports_the_same_path() {
let root = std::env::temp_dir().join(format!("ull-logs-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join(".ULL/logs");
assert!(!dir.is_dir(), "夹具目录不该已经存在");
let prepared = prepare(&dir).expect("应当建得出来");
assert_eq!(prepared, dir);
assert!(dir.is_dir(), "打开之前必须真的存在");
std::fs::remove_dir_all(&root).ok();
}
/// 失败文本带路径(「不许只在成功时可用」)。
#[test]
fn a_failure_names_the_path_it_could_not_use() {
let file = std::env::temp_dir().join(format!("ull-logs-notadir-{}", std::process::id()));
std::fs::write(&file, b"x").expect("写夹具");
let error = prepare(&file).expect_err("一个文件不是目录");
assert!(error.contains(&file.display().to_string()), "{error}");
std::fs::remove_file(&file).ok();
}
}
+2
View File
@@ -67,6 +67,8 @@ pub enum Message {
Captured(window::Screenshot),
/// gallery(组件库 harness)里的演示交互。
Demo(components::gallery::DemoMessage),
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
Dialog(super::dialog::Message),
}
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
+3
View File
@@ -30,6 +30,9 @@
mod appearance;
mod backend;
mod boot;
pub(crate) mod diag;
mod dialog;
pub(crate) mod logs_folder;
mod message;
mod state;
mod subscription;
+13
View File
@@ -56,6 +56,9 @@ pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TI
/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒
/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`:
/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。
///
/// **M192 的第三条时钟不在这里**:动效(`components::motion`)也只在「有轨道」时借用帧订阅,
/// 最后一个轨道到期就清空 ⇒ 下一拍 [`App::needs_frames`] 自动退回这条 1 Hz 心跳。
pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1);
/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。
@@ -193,6 +196,16 @@ pub struct App {
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
pub(super) launch_form: pages::LaunchForm,
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
///
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
/// 是两件事——队列管弹窗的排队与按钮,卡片管证据(路径 / 崩溃摘要 / 捕获输出)。
pub(super) dialog_card: Option<super::dialog::CrashCard>,
/// 弹窗的**视图模型**(在 [`App::refresh_view`] 里重建;`view()` 只借它)。
///
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
pub(super) dialog_model: Option<super::dialog::Modal>,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
+2 -1
View File
@@ -4,6 +4,7 @@
//! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出,
//! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。
use crate::app::diag::diag;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use std::time::Instant;
@@ -32,7 +33,7 @@ pub(crate) fn log_first_frame() {
return;
}
if let Some(started) = STARTED.get() {
eprintln!(
diag!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0
);
@@ -41,6 +41,9 @@ impl App {
Some(root) => SettingsBridge::with_path(config_path(root)),
None => SettingsBridge::open(),
};
// M188:日志目录也在这一拍登记——它是从**同一份**设置桥反推的(`--data-dir` 同样生效),
// 所以设置页显示的路径与 `launcher.log` 的真实落点不会各说各话。
crate::app::logs_folder::register(&bridge);
Self::with_presenter(options, families, flavor, backend, bridge)
}
@@ -135,6 +138,8 @@ impl App {
launch_form: pages::LaunchForm::default(),
install_cancelled_seen: 0,
launch_ends_seen: 0,
dialog_card: None,
dialog_model: None,
content: pages::content::PageState::default(),
account_form: pages::account::Form::new(),
account: pages::account::Page::build(
@@ -151,6 +156,8 @@ impl App {
app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings();
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
app.hydrate_java_settings();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log();
+6
View File
@@ -51,6 +51,9 @@ impl App {
ToastKind::Error
};
self.state.toasts.push(kind, text);
// M187:失败要**弹窗**(Toast 会自己消失,崩溃现场不能只靠它)。
let ended = finished.clone();
self.open_failure_dialog(&ended);
}
}
@@ -78,6 +81,9 @@ impl App {
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width);
// M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量
// (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。
self.dialog_model = self.dialog_modal();
self.view_dirty = false;
self.refreshes += 1;
}
+3 -2
View File
@@ -5,6 +5,7 @@
//!
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
use crate::app::diag::diag;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::ThemeMode;
@@ -30,7 +31,7 @@ impl App {
self.appearance.set_flavor(flavor);
let previous = self.ui.name();
self.rebuild_ui();
eprintln!(
diag!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
previous,
self.ui.name()
@@ -48,7 +49,7 @@ impl App {
let panel_alpha = self.appearance.effective_panel_alpha();
self.ui = match self.appearance.derived_scheme() {
Some(scheme) => {
eprintln!(
diag!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level()
);
+256
View File
@@ -11,10 +11,14 @@
//! | 条件 | 为什么需要 33 ms 节拍 |
//! |---|---|
//! | 截图通道 | 第 3 帧要落盘 |
//! | **有动画在跑**(M192) | 动效时间轴要逐帧采样(帧泵自己按 16 ms 结算,见 `components::motion`) |
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
//! | 有未过期的 toast | toast 按毫秒倒计时 |
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
//!
//! 上面五条**共享同一个布尔**:订阅表里 `window::frames()` 只出现一处(写在同一个 `if` 里),
//! 所以「动画」与「观察窗 / Busy」同时成立时也只有一条订阅——不双订阅靠的是结构,不是判重。
//!
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
//!
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
@@ -43,6 +47,7 @@ use std::time::Instant;
use iced::{window, Subscription};
use super::components::motion;
use super::message::Message;
use super::state::App;
@@ -85,10 +90,261 @@ impl App {
/// 现在要不要**时钟**(见模块文档的表)。
pub(super) fn needs_frames(&self) -> bool {
self.has_screenshot()
// M192:只有动画在跑时才多这一条(帧泵清空轨道后立刻为假 ⇒ 退回 1 Hz 心跳)。
|| motion::needs_frames()
|| !self.state.toasts.is_empty()
|| !self.state.running().is_empty()
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|| self.settings.java.scanning.is_some()
|| self
.watch_until
.is_some_and(|deadline| Instant::now() < deadline)
}
}
#[cfg(test)]
mod tests {
//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。
//!
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
//!
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。
//! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表,
//! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。
//!
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
use super::*;
use crate::app::components::motion::{Slot, FRAME_MS};
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 走到「真·空闲」:没有观察窗、没有忙碌实例、没有动画(首屏那一条 Boot 已跑完)。
///
/// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完,
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
fn idle_app() -> App {
motion::reset();
motion::configure(MotionScale::STANDARD, None);
let mut app = headless();
// 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档)
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// **按运行时顺序**:空闲态一条消息进来(换页)⇒ **同一拍**订阅表就要有帧;跑完立刻退订。
///
/// 变异证明:把起跑搬回 `view`(删掉 `App::update` 里的 `sync_motion`)⇒ 第一步的
/// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。
#[test]
fn a_state_change_opens_the_frame_subscription_in_the_same_tick() {
let mut app = idle_app();
assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧");
// 用户点了一次导航轨:**一条**消息,之后不再补任何消息
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert!(
app.watch_until.is_none(),
"换页不该打开观察窗(这里要证明帧是**动效**要来的,不是派发顺手开的)"
);
assert!(motion::needs_frames(), "换页必须在同一拍起跑");
assert!(
app.needs_frames(),
"同一拍的订阅表必须能看见轨道(iced 先算订阅表、后建视图 —— 复审 P1-1)"
);
run_until_idle();
assert!(!motion::needs_frames(), "动画跑完帧泵必须清空");
assert!(
!app.needs_frames(),
"动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)"
);
motion::reset();
}
/// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。
#[test]
fn opening_a_detail_also_opens_the_frames_in_the_same_tick() {
let mut app = idle_app();
// 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
run_until_idle();
assert!(!app.needs_frames());
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert!(app.needs_frames(), "点进详情必须当拍订阅帧");
run_until_idle();
assert!(!app.needs_frames());
motion::reset();
}
/// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。
#[test]
fn pages_that_render_elsewhere_do_not_start_a_track() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts)));
assert!(
!motion::needs_frames(),
"账户页没有 pages 树可动,不许白开帧"
);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert!(motion::needs_frames(), "从账户页回到某一屏仍然是换页,要跑");
run_until_idle();
motion::reset();
}
/// **没有窗口就不起跑**:`window::frames()` 在没窗口时一条消息都不会有,起跑只是白占一档;
/// 首屏那一条也因此要等窗口到了才算「第一眼」。
#[test]
fn without_a_window_nothing_animates() {
motion::reset();
motion::configure(MotionScale::STANDARD, None);
let mut app = headless();
assert!(app.window.is_none(), "前提:还没拿到窗口句柄");
let _ = app.update(Message::Boot);
app.watch_until = None;
assert!(!motion::needs_frames(), "没有窗口 ⇒ 不许起跑");
// 窗口来了:首屏入场这时才开始(不是被启动期那棵空树吃掉)
let _ = app.update(Message::WindowReady(Some(iced::window::Id::unique())));
assert!(app.needs_frames(), "窗口到了之后「首屏」才起跑");
run_until_idle();
motion::reset();
}
/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。
///
/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。
#[test]
fn no_scale_requests_no_frames_at_all() {
motion::reset();
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
motion::configure(MotionScale::NONE, None);
// 三种状态变化:换页 / 换详情 / 再换回来
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert!(!app.needs_frames(), "关掉动效后换页一帧都不该订阅");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert!(!app.needs_frames());
run_until_idle();
assert!(!motion::needs_frames());
assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0");
motion::reset();
}
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。
///
/// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看
/// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`——
/// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。
#[test]
fn there_is_exactly_one_frame_subscription() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rust_files(&src, &mut files);
assert!(
files.len() >= 40,
"只扫到 {} 个文件:扫描逻辑坏了",
files.len()
);
let mut hits = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读源文件");
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
let code = product
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join(
"
",
);
let count = code.matches("window::frames()").count();
if count > 0 {
let name = path
.strip_prefix(&src)
.unwrap_or(&path)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
hits.push((name, count));
}
}
hits.sort();
let expected = [
("app/subscription.rs".to_string(), 1_usize),
("pages/harness.rs".to_string(), 1_usize),
];
assert_eq!(
hits, expected,
"`window::frames()` 只许出现在 `app/subscription.rs`(产品接线)\
与 `pages/harness.rs`(独立 example 的自有 app),且各一处"
);
}
/// 递归收集 `dir/**/*.rs`。
fn collect_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("读目录") {
let path = entry.expect("目录项").path();
if path.is_dir() {
collect_rust_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。
#[test]
fn slots_are_the_three_documented_ones() {
assert_eq!(Slot::ALL.len(), 3);
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]);
}
}
+99 -12
View File
@@ -20,11 +20,15 @@
//!
//! | 场景 | 时钟 | 周期 | 谁触发 |
//! |---|---|---|---|
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
//! | 截图 / **有动画在跑(M192)** / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
//!
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
//! 动效那条**不新开循环**:它只是给同一个布尔加一个分支(有轨道才为真),轨道到期当场清空,
//! 下一拍就退回心跳(见 `components::motion`)。
use crate::app::components::motion;
use crate::app::diag::diag;
use std::time::Instant;
use iced::{window, Task};
@@ -44,19 +48,44 @@ use super::CAPTURE_AT_FRAME;
mod account;
mod agent;
mod appearance;
mod boot_env;
pub(super) mod boot_env;
mod clock;
mod content;
mod content_pack;
mod dialog;
mod dialog_fixture;
mod download;
mod instances;
mod motion_sync;
mod page_messages;
mod settings;
mod update_action;
/// 这一条键盘事件是不是 `Esc` 按下。
fn is_escape(event: &iced::keyboard::Event) -> bool {
matches!(
event,
iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}
)
}
impl App {
/// 一条消息进来,改状态并返回后续任务。
///
/// **分两段**:先派发([`App::route_message`]),再同步一次动效的内容标识。第二段必须在
/// `update` 里而不是 `view` 里:iced 求值订阅表**早于**建视图(`app/update/motion.rs` 的模块
/// 文档有源码行号),在 `view` 里起跑的轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」。
pub(super) fn update(&mut self, message: Message) -> Task<Message> {
let task = self.route_message(message);
self.sync_motion();
task
}
/// 把一条消息派发到它的落点(`update` 的正文;拆出来只为让 [`App::sync_motion`] 收尾)。
fn route_message(&mut self, message: Message) -> Task<Message> {
match message {
Message::Boot => self.on_boot(),
Message::WindowReady(id) => {
@@ -75,7 +104,7 @@ impl App {
match hwnd {
Some(_) => self.apply_window_alpha("boot"),
None => {
eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
diag!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
self.log_appearance("boot", None);
}
}
@@ -89,7 +118,7 @@ impl App {
iced::theme::Mode::Dark => SchemeFlavor::Dark,
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
};
eprintln!("[theme] 系统主题 = {mode:?}");
diag!("[theme] 系统主题 = {mode:?}");
// 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。
// 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现
// `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。
@@ -119,7 +148,16 @@ impl App {
Task::none()
}
Message::Page(message) => self.on_page(message),
Message::Agent(message) => self.on_agent(message),
Message::Dialog(message) => dialog::on_dialog(self, message),
Message::Agent(message) => {
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
if let crate::shell::agent::Message::Key(event) = &message {
if is_escape(event) && dialog::escape(self) {
return Task::none();
}
}
self.on_agent(message)
}
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
Message::Select(nav) => {
self.state.navigate(route_of(nav));
@@ -147,7 +185,7 @@ impl App {
Message::Captured(screenshot) => self.on_captured(&screenshot),
Message::Demo(message) => {
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
eprintln!("[gallery] {} pressed", message.id());
diag!("[gallery] {} pressed", message.id());
Task::none()
}
}
@@ -160,12 +198,16 @@ impl App {
fn on_boot(&mut self) -> Task<Message> {
let mut tasks = vec![window::latest().map(Message::WindowReady)];
// M192:给动效帧泵定档(截图档关、CPU 档半速、GPU 档正常;`ULL_MOTION*` 可覆盖)。
// 必须在第一次 `view()` 之前:页面主体的入场由 `pages::render` 采样,配置晚了首屏就没有动效。
self.configure_motion();
// M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。
// 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络;
// 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面,
// 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨
// 「没配置」与「配了但登录失败」。
eprintln!("{}", unlucky_cli::commands::msa_client_id_log_line());
diag!("{}", unlucky_cli::commands::msa_client_id_log_line());
// 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
@@ -176,6 +218,8 @@ impl App {
self.appearance.apply_env();
self.rebuild_ui();
self.log_appearance("boot", None);
// M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。
boot_env::record_boot_log(self);
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
if self.options.wants_system_theme() {
@@ -194,7 +238,7 @@ impl App {
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
if let Some(route) = boot_env::env_start_route() {
eprintln!("[boot] ULL_START_ROUTE → {route:?}");
diag!("[boot] ULL_START_ROUTE → {route:?}");
self.state.navigate(route);
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true;
@@ -202,6 +246,10 @@ impl App {
self.ensure_content_loaded();
}
account::force_account_state(self);
// M187:启动失败弹窗的取证夹具(`ULL_DIALOG_FIXTURE`),必须在 `refresh_view` 之前。
if let Some(mode) = dialog_fixture::apply(self) {
diag!("[boot] ULL_DIALOG_FIXTURE → {mode}");
}
self.auto_select();
self.view_dirty = true;
@@ -213,27 +261,66 @@ impl App {
Task::batch(tasks)
}
/// M192:给帧泵定档(策略全在 [`boot_env::motion_scale`],这里只搬输入)。
fn configure_motion(&mut self) {
let pin = boot_env::env_motion_pin();
let gpu = matches!(
super::backend::effective_kind(),
Some(crate::renderer::Kind::Wgpu)
);
let scale = boot_env::motion_scale(self.options.screenshot.is_some(), gpu, pin.is_some());
motion::configure(scale, pin);
// 打**帧泵实际拿到的**档位(不是刚算出的那个):配置没生效时这一行会露馅。
diag!(
"[motion] scale={:.2} pin={:?} gpu={} screenshot={}",
motion::scale().factor(),
pin,
gpu,
self.options.screenshot.is_some()
);
}
/// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。
///
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
fn on_heartbeat(&mut self) -> Task<Message> {
// 动效时间轴也吃这一拍:窗口被遮挡 / 最小化 / 合成器不送绘制时,帧一个都不来,而
// 「有轨道」又让下面的 1 Hz 推进整段停摆——那会让 `needs_frames()` 永远为真。心跳把
// 轨道按单次上限([`components::motion::pump::MAX_STEP_MS`])推几步收尾,最坏的「没有帧」
// 情况下 5 拍内自然退订;有帧时它只多推 64 ms/秒,看不出来。
motion::advance();
if !self.needs_frames() {
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK);
self.last_tick = now;
self.advance(elapsed);
}
// M192 真机取证(`ULL_MOTION_FIXTURE_AT`):到点就**真的**导航一次。放在 advance 之后 ⇒
// 这一拍是货真价实的空闲态;跳转后由 `update` 收尾的 `sync_motion` 起跑(与点击同一条路)。
self.motion_fixture_after_idle();
heartbeat()
}
/// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。
/// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第
/// [`CAPTURE_AT_FRAME`] 帧出图。
///
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
fn on_frame(&mut self) -> Task<Message> {
self.frames += 1;
// M192:动效时间轴。**每帧都调**,帧率上限在帧泵内部(16 ms 一次结算),所以忙碌态的
// 近千 fps 不会变成近千次重排;动画结束后帧泵自动清空,下一拍订阅表就不再有 `frames()`。
motion::advance();
// 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。
// 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。
// `ULL_MOTION_FIXTURE_AT`(真机空闲取证)在的时候**不跑**这一支:两条取证路互斥,
// 否则第 1 帧就把页面换成目标页,等 `_AT` 的心跳再跳同一个目标 = 标识没变 = 没有动效。
if self.frames <= 2 && boot_env::env_motion_fixture_at().is_none() {
self.motion_fixture();
}
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick);
if elapsed >= TICK {
@@ -259,9 +346,9 @@ impl App {
};
match crate::shot::save(screenshot, &path) {
Ok(summary) => {
eprintln!("{}", summary.describe());
diag!("{}", summary.describe());
// M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。
eprintln!(
diag!(
"[shot] screen={} theme={} renderer={} route={:?}",
self.options.screen.name(),
crate::shot::flavor_slug(&self.ui),
@@ -269,7 +356,7 @@ impl App {
self.state.route()
);
}
Err(error) => eprintln!("[shot] 失败:{error}"),
Err(error) => diag!("[shot] 失败:{error}"),
}
iced::exit()
+25 -2
View File
@@ -95,7 +95,7 @@ impl App {
self.dispatch(AppCommand::AddAuthlibAccount(request));
}
}
Panel::Microsoft => return self.on_account(Account::StartBrowser),
Panel::Microsoft => return self.on_account(Account::StartDevice),
Panel::None => {}
},
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
@@ -136,7 +136,8 @@ impl App {
match account.kind {
AccountKind::Microsoft => {
self.account_form.panel = Panel::Microsoft;
self.dispatch(AppCommand::StartBrowserLogin);
// M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。
self.dispatch(AppCommand::StartDeviceLogin);
}
AccountKind::Authlib => {
self.account_form.open(Track::Authlib);
@@ -173,6 +174,23 @@ pub(super) fn force_account_state(app: &mut App) {
app.state.device_login_started =
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
}
// M178:浏览器登录没走成、已回退设备码的那一态(设备码 + 一句「为什么 + 怎么修」)。
//
// 为什么在 app 层造:真走到这一步要一次真网络请求(预检),截图通道要的是**确定性**。
// 这里只替换登录视图本身——面板的渲染路径与真 host 完全同一条
// (`apply_device_login` → `pages::account::panels::device_panel` → `login::panel`)。
"msa-fallback" => {
app.account_form.panel = crate::pages::account::Panel::Microsoft;
app.state.apply_device_login(
unlucky_presenter::dto::DeviceLoginView::waiting(
"ABCD-EFGH",
"https://www.microsoft.com/link",
900,
"",
)
.with_browser_note(unlucky_presenter::dto::BrowserLoginNote::RedirectNotRegistered),
);
}
// 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
"authlib-failed" => {
app.account_form.panel = crate::pages::account::Panel::Authlib;
@@ -198,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) {
"closed" => {
let _ = app.on_account(crate::pages::account::Message::Close);
}
// M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动
// 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。
"msa-entry" => {
app.account_form.panel = crate::pages::account::Panel::Microsoft;
}
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
"add" => {
app.account_form.section_open = true;
@@ -125,10 +125,10 @@ fn the_device_code_flow_is_started_visible_and_cancellable() {
let _ = app.on_account(Account::StartDevice);
match &app.account.device {
DevicePanel::Code {
code, remaining, ..
code, countdown, ..
} => {
assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑");
assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)");
}
other => panic!("进行中状态必须可见,实际 {other:?}"),
}
+6 -5
View File
@@ -29,6 +29,7 @@
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
use crate::app::diag::diag;
use iced::keyboard::{key, Key};
use iced::Task;
@@ -141,10 +142,10 @@ impl App {
self.drain_agent();
}
SubmitOutcome::Failed(failure) => {
eprintln!("[agent] 提交失败({})", failure.code());
diag!("[agent] 提交失败({})", failure.code());
}
SubmitOutcome::Rejected => {
eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
diag!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
}
}
}
@@ -204,7 +205,7 @@ impl App {
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
if pending && !on_confirm {
self.agent.focus = Focus::ConfirmAllow;
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
diag!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
} else if !pending && on_confirm {
self.agent.focus = Focus::Composer;
self.agent.keyboard_mode = false;
@@ -279,7 +280,7 @@ impl App {
}
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
eprintln!("[agent] rail=detached → Route::Agent");
diag!("[agent] rail=detached → Route::Agent");
self.state.navigate(Route::Agent);
// 直达独立页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true;
@@ -371,7 +372,7 @@ impl App {
let _ = self.on_agent(message);
} else {
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
eprintln!(
diag!(
"[agent] Enter on focus={} handled by the composer",
self.agent.focus.name()
);
+2 -1
View File
@@ -28,6 +28,7 @@
//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
//! (M116 取消了 8/12/16 固定档)。
use crate::app::diag::diag;
use crate::platform;
use crate::platform::win32::corner::{self, CornerApplied};
use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome};
@@ -73,7 +74,7 @@ impl App {
"frame=? layered=?".to_owned(),
),
};
eprintln!(
diag!(
"{}",
corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by)
);
+149 -2
View File
@@ -1,8 +1,11 @@
//! 启动期的取证环境变量解析(`ULL_START_ROUTE`)。
//! 启动期的取证环境变量解析(`ULL_START_ROUTE` / 动效三个开关)。
//!
//! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线):
//! **纯函数、无状态**,只是取值表长到了七条。
//! **纯函数、无状态**,只是取值表长到了十条。M192 在这里加的是动效那一组
//! ([`motion_scale`] / [`env_motion_pin`] / [`env_motion_fixture`]):档位策略只有这一处,
//! 帧泵自己不读环境变量。
use unlucky_design::motion::MotionScale;
use unlucky_presenter::router::Route;
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
@@ -24,3 +27,147 @@ pub(super) fn env_start_route() -> Option<Route> {
_ => None,
}
}
/// 动效档位与相位钉住(M192):帧泵**只在产品的 `on_boot` 里定档**。
///
/// ## 为什么档位在这里算
///
/// 三个输入只有启动这一拍齐全:①是不是截图通道(`--screenshot`)②实际渲染后端
/// (`backend::effective_kind()`,不是 `--renderer` 的请求值)③`ULL_MOTION` 的强制值。
/// 帧泵自己(`components::motion`)不读环境变量,它只接受一个 [`MotionScale`]——这样动效策略
/// 只有一处,测试也不必摆环境变量。
///
/// | 条件 | 档位 | 为什么 |
/// |---|---|---|
/// | `ULL_MOTION=off` | `NONE` | 一键关(排障 / 想量基线时用) |
/// | 截图通道,且没给 `ULL_MOTION_AT` | `NONE` | D30:出图不许有动画(否则第 3 帧抓到中间态,图就不可复现) |
/// | CPU 渲染(`tiny-skia`) | `HALF` | 时长减半 = 帧数减半。位移是**布局量**,CPU 档最贵的就是逐帧重排 |
/// | 其余(`wgpu`) | `STANDARD` | —— |
/// | `ULL_MOTION=half` / `full` | 强制 | 覆盖上表(出图矩阵要在 GPU 上量同一段动效) |
///
/// 「减少动效」的用户开关**还不存在**(`core/src/setting` 里没有 animation key,见报告 follow-up),
/// 所以本函数只认上面这些来源。
pub(super) fn motion_scale(screenshot: bool, gpu: bool, pinned: bool) -> MotionScale {
match std::env::var("ULL_MOTION").ok().as_deref().map(str::trim) {
Some("off") | Some("none") => return MotionScale::NONE,
Some("half") => return MotionScale::HALF,
Some("full") => return MotionScale::STANDARD,
_ => {}
}
if pinned {
// 取证相位优先于「截图必须静」,否则多帧序列图一张都出不来。
return MotionScale::STANDARD;
}
if screenshot {
return MotionScale::NONE;
}
if gpu {
MotionScale::STANDARD
} else {
MotionScale::HALF
}
}
/// `ULL_MOTION_AT=<ms>`:把动画时钟钉在某一毫秒(多帧序列图取证)。
///
/// 语义与 `ULL_APPEARANCE_*` / `ULL_DIALOG_FIXTURE` 一致:只影响这一次进程。没有它,第 3 帧落在
/// 动画的哪个相位取决于这台机器的渲染速度 —— 那样「0% / 50% / 100% 三张图」是抓阄抓出来的。
pub(super) fn env_motion_pin() -> Option<f64> {
std::env::var("ULL_MOTION_AT")
.ok()?
.trim()
.parse::<f64>()
.ok()
.filter(|ms| ms.is_finite() && *ms >= 0.0)
}
/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。
///
/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::observe`),而截图通道
/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。
#[derive(Debug, Clone)]
pub(super) enum MotionFixture {
/// 跳到一个一级页(走「换页」那一档)。
Route(Route),
/// 「列表 → 详情」:先回列表(第 1 帧),再点进第一条实例(第 2 帧)。
Detail,
}
/// `ULL_MOTION_FIXTURE=<instances|download|logs|settings|content|detail>`。
pub(super) fn env_motion_fixture() -> Option<MotionFixture> {
Some(match std::env::var("ULL_MOTION_FIXTURE").ok()?.trim() {
"instances" => MotionFixture::Route(Route::Instances),
"download" => MotionFixture::Route(Route::Download),
"logs" => MotionFixture::Route(Route::Logs),
"settings" => MotionFixture::Route(Route::Settings),
"content" => MotionFixture::Route(Route::Content),
"detail" => MotionFixture::Detail,
_ => return None,
})
}
/// `ULL_MOTION_FIXTURE_AT=<秒>`:启动后第 N 次心跳(≈第 N 秒)**真的**导航一次。
///
/// ## 为什么需要它(真机空闲取证)
///
/// 复审 r1 要求「空闲 ≥10 s 后点一次导航轨,看 `[tick] motion=` 涨多少」。本机工作区所在会话
/// 没有前台窗口(`GetForegroundWindow()==0`),合成鼠标事件(`mouse_event` 与
/// `PostMessage(WM_LBUTTONDOWN)` 都试过)递不进 iced 的输入层,所以这里换一条**同构**的路:
/// 由 1 Hz 心跳(空闲态本来就有的消息)在第 N 秒做一次真实的路由跳转。
///
/// 两条路在 `update` 之后**完全同一段代码**:消息 → `sync_motion`(起跑)→ `subscription()`
/// (同一拍看见轨道)→ `frames()`。差别只在「消息是谁产生的」,而这正是 P1 要验的那一段。
pub(super) fn env_motion_fixture_at() -> Option<u32> {
std::env::var("ULL_MOTION_FIXTURE_AT")
.ok()?
.trim()
.parse::<u32>()
.ok()
.filter(|beats| *beats > 0)
}
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
///
/// ## 为什么这一拍只能「先记、后落」
///
/// `.ULL/logs` 的落点由 `platform::data_dir` 的三模式决定(要读配置、follow `redirect.json`),
/// 那件事只有 presenter 的 `HostEnv` 做过。app 层拿不到它,于是这里只 `record`
/// (core 侧缓冲,最多 200 行);presenter **构造 `HostEnv` 的那一刻**就调
/// `launcher_log::set_dir`(M188 补上的那一半),缓冲一次刷进 `<.ULL>/logs/launcher.log`。
///
/// 效果正是用户投诉要的那份东西:他「双击 → 启动 → 崩 → 找日志」时,`launcher.log` 里从
/// **启动这一拍**(主题 / 语言 / 路由 / 窗口 / 后端)一直写到退出,而不是只有 launch 那一段。
///
/// M188 之后边界也变了:`boot.rs` / `boot/fallback.rs` / `state/*` 那些 `eprintln!` 改走
/// [`crate::app::diag::emit`](stderr + 文件双写),同样靠这条缓冲接住——所以「一局都没开就崩」
/// 的进程也会留下 `launcher.log`。
pub(crate) fn record_boot_log(app: &super::super::state::App) {
use unlucky_core::launch::launcher_log;
launcher_log::record("boot", "app boot");
launcher_log::record(
"boot",
&format!(
"theme={} lang={:?} route={:?} window={:.0}x{:.0} screenshot={} instances={}",
app.ui().name(),
app.lang(),
app.state.route(),
app.width,
app.height,
app.has_screenshot(),
app.state.instances.len(),
),
);
launcher_log::record("boot", &unlucky_cli::commands::msa_client_id_log_line());
// 后端这一拍也要在日志里:`[probe]` / `[backend]` 那几行由 `renderer::acceptance` 打到
// stderr(那个模块不在 M188 的 scope),事故之后翻日志的人至少要看得出「这次用的是哪个后端」。
launcher_log::record(
"boot",
&format!(
"backend request={} effective={} renderer_fallback={}",
app.options.backend.name(),
super::super::backend::effective_backend(app.options.backend).name(),
std::env::var(crate::renderer::FALLBACK_ENV).unwrap_or_else(|_| "-".to_string()),
),
);
}
+13 -4
View File
@@ -17,6 +17,7 @@
//! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干,
//! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。
use crate::app::diag::diag;
use std::time::{Duration, Instant};
use unlucky_presenter::dto::AppCommand;
@@ -49,6 +50,9 @@ impl App {
self.font_seen = font_status;
self.view_dirty = true;
}
// Java 扫描(M175):同一条「后台线程 + 快照」路数。进度(3/7)与结论都在里面,
// 扫描期间按帧跑(见 `subscription::needs_frames`),扫完回到 1 Hz。
self.hydrate_java_scan();
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
@@ -71,12 +75,14 @@ impl App {
let reloaded =
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
if reloaded {
eprintln!("[settings] 配置文件变了,重读主题 + 外观");
diag!("[settings] 配置文件变了,重读主题 + 外观");
self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
self.reload_appearance();
// M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。
self.hydrate_ai_settings();
// M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。
self.hydrate_java_settings();
}
if self.view_dirty {
@@ -87,8 +93,8 @@ impl App {
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
eprintln!(
"[tick] n={} frames={} refreshes={} clock={}",
diag!(
"[tick] n={} frames={} refreshes={} clock={} motion={}",
self.ticks,
self.frames,
self.refreshes,
@@ -96,7 +102,10 @@ impl App {
"frames"
} else {
"idle-1hz"
}
},
// M192:动效时间轴已结算的帧数(每秒一行 ⇒ 这一列的增长速度就是动画的实际帧率,
// 也是「动画期间重绘成本」的记账口径,见报告)
crate::app::components::motion::samples()
);
}
}
+14 -2
View File
@@ -16,6 +16,7 @@
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
use crate::app::diag::diag;
use unlucky_presenter::dto::{
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
ExportRequestView,
@@ -103,6 +104,15 @@ impl App {
self.dispatch(AppCommand::InstallContent(Box::new(request)));
}
}
// M191:「只重试上次失败的那几项」。请求构造在 `pages/content/model.rs`
// (`retry_install_request`:载荷带 `retry: true`),这里只做转发。
pages::content::Message::RetryFailed => {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
if let Some(request) = model.retry_install_request() {
self.dispatch(AppCommand::InstallContent(Box::new(request)));
}
}
// M113: import/export —— 导入/导出区(细节在 `on_pack`)
pages::content::Message::Pack(message) => self.on_pack(message),
}
@@ -199,10 +209,10 @@ impl App {
if !model.can_install() {
// 有原因就打印出来(取证用);没有选中项时什么都不做
for reason in model.blocker_reasons(catalog) {
eprintln!("[content] 装入被挡下:{reason}");
diag!("[content] 装入被挡下:{reason}");
}
if model.blockers.contains(&Blocker::NeedsPipeline) {
eprintln!("[content] 整合包装入未实现(M111)");
diag!("[content] 整合包装入未实现(M111)");
}
return None;
}
@@ -221,6 +231,8 @@ impl App {
instance: target.id.clone(),
game_version: target.mc_version.clone(),
loader: target.loader,
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
retry: false,
})
}
}
+104
View File
@@ -0,0 +1,104 @@
//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。
//!
//! 只有两类副作用:**剪贴板**(返回 `Task`,必须交回 runtime)与**弹 Toast**(就地推)。
//! 导出与打开文件夹是同步的本地动作(写一个 zip / 拉起一个 `explorer`),耗时都在毫秒级,
//! 不为它们单开线程——真要慢(网络盘)也是用户能感知的一次点击。
use iced::Task;
use unlucky_presenter::toast::ToastKind;
use crate::app::dialog::{export, Message as DialogMessage};
use crate::app::state::App;
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
match message {
DialogMessage::Primary => {
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
let Some(card) = app.dialog_card.clone() else {
app.close_dialog();
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 => {
app.close_dialog();
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
// (r1 复审 P3-1)。
app.refresh_view();
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),
);
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),
);
iced::clipboard::write(path.display().to_string())
}
}
}
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
pub(super) fn escape(app: &mut App) -> bool {
if app.state.dialogs.current().is_none() {
return false;
}
app.close_dialog();
app.refresh_view();
true
}
@@ -0,0 +1,152 @@
//! 启动失败弹窗的**取证夹具**(`ULL_DIALOG_FIXTURE`,只在 `--screenshot` 下生效)。
//!
//! ## 为什么要有它
//!
//! 弹窗要出两张图(「有 crash-report」/「只有捕获输出」),而真机上要复现一次崩溃得先让一个
//! 真实的 MC 进程死掉——截图通道要的是**确定性**。做法与 `ULL_ACCOUNT_STATE` 同一档:
//! 在临时目录里造一份**真的文件树**(launcher.log / 会话 JSONL / crash-reports / latest.log),
//! 于是弹窗上的路径是真路径、点「导出诊断包」真的会打出一个装得下这些文件的 zip。
//!
//! 取值:
//!
//! | 值 | 现场 |
//! |---|---|
//! | `crash-report` | 游戏写了 `crash-reports/crash-*.txt`(证据一) |
//! | `no-logs` | 没有 crash-report、没有 latest.log,只有会话 JSONL 里的 stderr(证据二) |
//! | `export` | 同 `no-logs`,并在出图前**真的跑一次导出**(第三张图:路径 + 字节数) |
//!
//! 夹具只影响这一次进程;产品路径(`host/launch.rs` 收尾 → `crash_report::publish` →
//! `refresh_view` → 弹窗)与它完全同一条,这里只是**代填事实**。
use std::path::{Path, PathBuf};
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
use unlucky_core::launch::crash_scan::{self, CrashSource};
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
use crate::app::state::App;
/// 现场根(`%TEMP%/ull-m187-fixture-<pid>`)。
fn root() -> PathBuf {
std::env::temp_dir().join(format!("ull-m187-fixture-{}", std::process::id()))
}
fn write(path: &Path, text: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, text);
}
/// 出图用的夹具(`None` = 没有开关,什么都不做)。
pub(super) fn apply(app: &mut App) -> Option<String> {
let mode = std::env::var("ULL_DIALOG_FIXTURE").ok()?;
let mode = mode.trim().to_lowercase();
if !app.has_screenshot() {
return None;
}
let root = root();
let _ = std::fs::remove_dir_all(&root);
let logs = root.join("logs");
write(
&logs.join("launcher.log"),
"2026-11-08T15:30:11.002Z [boot] renderer=wgpu\n\
2026-11-08T15:30:12.441Z [launch] instance=survival spawn game_dir=… java=javaw (21)\n\
2026-11-08T15:30:19.870Z [launch] instance=survival session=9f2c exit=1\n",
);
write(&logs.join("install.log"), "installed 1.20.4-fabric\n");
let session = "9f2c4a1e-77bd-4f27-9d21-5b0a1f2c3d4e";
let session_log = root
.join("sessions")
.join("game")
.join(format!("{session}.jsonl"));
write(
&session_log,
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"[main/INFO]: Loading Minecraft 1.20.4\"}}\n\
{\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.RuntimeException: mixin injection failed\"}}\n\
{\"kind\":\"stderr\",\"payload\":{\"line\":\"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW\"}}\n\
{\"kind\":\"exit\",\"payload\":{\"code\":1}}\n",
);
let tail: Vec<String> = vec![
"[main/INFO]: Loading Minecraft 1.20.4".to_string(),
"java.lang.RuntimeException: mixin injection failed".to_string(),
"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW".to_string(),
];
let run_dir = root.join("instances").join("survival");
let latest = run_dir.join("logs").join("latest.log");
let version_json = root
.join("game")
.join("versions")
.join("1.20.4-fabric")
.join("1.20.4-fabric.json");
write(&version_json, "{\"id\":\"1.20.4-fabric\"}\n");
let mut artifacts: Vec<ArtifactFact> = Vec::new();
if mode != "no-logs" {
write(
&run_dir
.join("crash-reports")
.join("crash-2026-11-08_15.30.19-client.txt"),
"---- Minecraft Crash Report ----\n\
Time: 2026-11-08 15:30:19\n\
Description: Initializing game\n\n\
java.lang.RuntimeException: mixin injection failed\n\
\tat net.fabricmc.loader.impl.launch.knot.Knot.init(Knot.java:142)\n\
\tat net.minecraft.client.main.Main.main(Main.java:51)\n",
);
write(&latest, "[15:30:12] [main/INFO]: Loading Minecraft 1.20.4\n[15:30:19] [Render thread/ERROR]: GLFW init failed\n");
let since = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
artifacts = crash_scan::scan(&run_dir, since, 8)
.into_iter()
.map(|artifact| ArtifactFact {
source: artifact.source.as_str().to_string(),
path: artifact.path,
excerpt: artifact.excerpt,
})
.collect();
}
let report = CrashReport {
instance: "survival".to_string(),
name: "生存服的 1.20.4".to_string(),
version: "1.20.4-fabric".to_string(),
session: session.to_string(),
session_log: Some(session_log),
version_json: Some(version_json),
launcher_logs: Some(logs),
run_dir: Some(run_dir),
artifacts,
exit_code: Some(1),
exit_type: "application_error".to_string(),
uptime_ms: 7_400,
command: "javaw -Xmx4096m -Djava.library.path=<home>… ".to_string(),
java: "C:/Program Files/Java/jdk-21/bin/javaw.exe (major 21)".to_string(),
system: "os=windows version=10.0.26100 arch=x86_64".to_string(),
stage: None,
stage_detail: None,
stderr_tail: tail,
};
crash_report::publish(report);
let finished = LaunchFinished {
id: "survival".to_string(),
session: session.to_string(),
outcome: LogOutcome::Crashed,
exit_code: Some(1),
uptime_ms: 7_400,
failure: None,
};
app.open_failure_dialog(&finished);
if mode == "export" {
// 走**真的**那一条:与用户点「导出诊断包」同一个处理函数。
let _ = super::dialog::on_dialog(app, crate::app::dialog::Message::Primary);
}
Some(mode)
}
/// 夹具里崩溃报告的来源名(与 `CrashSource::CrashReport` 同源,测试用)。
#[allow(dead_code)]
pub(super) fn crash_report_source() -> &'static str {
CrashSource::CrashReport.as_str()
}
@@ -0,0 +1,166 @@
//! 动效的**起跑点**(M192 r1 复审 P1-1 修的正是这里)。
//!
//! ## 为什么这条不能在 `view` 里
//!
//! iced 0.14 的处理顺序是:
//!
//! ```text
//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338)
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
//! ```
//!
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
//! **结构上**不可能暴露这条。
//!
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
//!
//! ## 标识从哪来
//!
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑,
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
use unlucky_presenter::router::Route;
use crate::app::components::motion;
use crate::pages;
use super::boot_env::MotionFixture;
use super::*;
impl App {
/// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。
pub(in crate::app) fn sync_motion(&mut self) {
// **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时
// `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义——
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
// 截图通道例外:它的窗口句柄也是异步到的,而序列图必须从第 1 帧就按钉住的相位画。
if self.window.is_none() && !self.has_screenshot() {
return;
}
let route = self.state.route();
let screen = screen_key(route);
let detail = match route {
Route::Detail(id) => Some(id.as_str()),
_ => None,
};
motion::observe(screen, detail);
}
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。
pub(in crate::app) fn motion_fixture(&mut self) {
let Some(fixture) = super::boot_env::env_motion_fixture() else {
return;
};
let route = match (fixture, self.frames) {
// 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。
(MotionFixture::Detail, 1) => Route::Instances,
(MotionFixture::Detail, 2) => {
let Some(first) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return;
};
Route::Detail(first)
}
(MotionFixture::Route(route), 1) => route,
_ => return,
};
diag!("[motion] ULL_MOTION_FIXTURE → {route:?}");
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
}
/// 真机空闲取证:`ULL_MOTION_FIXTURE_AT=<秒>` 到点就**真的**导航一次(`on_heartbeat` 调)。
///
/// 为什么不用点击:[`super::boot_env::env_motion_fixture_at`] 的文档写了原因——本机会话没有
/// 前台窗口,合成鼠标事件递不进 iced 的输入层。心跳这条路与点击在 `update` 之后是同一段代码。
/// 返回 `true` = 这一拍真的跳了页。
pub(in crate::app) fn motion_fixture_after_idle(&mut self) -> bool {
use std::sync::atomic::{AtomicU32, Ordering};
/// 已经过去的心跳数(进程内一次;只有取证开关打开时才会用到)。
static FIXTURE_BEATS: AtomicU32 = AtomicU32::new(0);
let Some(target) = boot_env::env_motion_fixture_at() else {
return false;
};
let beat = FIXTURE_BEATS.fetch_add(1, Ordering::Relaxed) + 1;
if beat != target {
return false;
}
let route = match boot_env::env_motion_fixture() {
Some(MotionFixture::Route(route)) => route,
Some(MotionFixture::Detail) => {
let Some(id) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return false;
};
Route::Detail(id)
}
None => Route::Download,
};
diag!("[motion] ULL_MOTION_FIXTURE_AT 到点(第 {beat} 次心跳)→ {route:?}");
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
true
}
}
/// 路由 → 屏的稳定键(`pages::Screen::ALL` 的下标 + 1;`None` = 不归 pages 画)。
///
/// 与 `pages::Screen::of` 同一判据(`Screen::Instances` ↔ `Route::Instances | Route::Detail(_)`),
/// 只是把「有没有这一屏」也表达出来——`pages::Screen::of` 给的是 `Option<Screen>`,这里
/// 再折成 `Option<u64>` 给帧泵用。
fn screen_key(route: &Route) -> Option<u64> {
let screen = pages::Screen::of(route)?;
pages::Screen::ALL
.iter()
.position(|candidate| *candidate == screen)
.map(|index| index as u64 + 1)
}
#[cfg(test)]
mod tests {
use super::*;
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
#[test]
fn only_the_five_screens_get_a_key() {
let keys: Vec<Option<u64>> = [
Route::Instances,
Route::Download,
Route::Content,
Route::Settings,
Route::Logs,
Route::Detail("survival".to_string()),
Route::Accounts,
Route::Agent,
]
.iter()
.map(screen_key)
.collect();
assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]);
assert_eq!(
keys[5],
Some(1),
"详情页归「实例」那一屏(与 Screen::of 同源)"
);
assert_eq!(keys[6], None, "账户页不归 pages 画");
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
}
}
@@ -21,6 +21,7 @@
use unlucky_i18n::{Lang, LangChoice};
use crate::app::diag::diag;
use crate::pages;
use crate::ui::lang::LangState;
@@ -94,9 +95,23 @@ impl App {
pages::Message::Instances(message) => return self.on_instance_message(message),
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
pages::Message::Account(message) => return self.on_account(message),
// M188:日志页与设置页的「打开日志文件夹」——同一个落点,路径从设置桥反推
// (三模式都对),失败文本带完整路径,并**送进剪贴板**(打不开时用户至少能粘出去)。
pages::Message::Action(pages::Action::OpenLogs) => {
return match crate::app::logs_folder::open() {
Ok(dir) => {
diag!("[logs] 已打开日志文件夹 {}", dir.display());
Task::none()
}
Err(error) => {
diag!("[logs] 打开日志文件夹失败:{error}");
iced::clipboard::write(error)
}
};
}
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => {
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
}
}
@@ -127,9 +142,7 @@ impl App {
self.lang = LangState::of(choice);
self.lang.log();
if !self.bridge.persist_ui_language(choice.setting_value()) {
eprintln!(
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
);
diag!("[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值");
}
self.lang.log_cjk();
self.view_dirty = true;
+275 -16
View File
@@ -1,18 +1,30 @@
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。
//! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
//!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
//! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
//!
//! 消息进来的通道有两条,都不经过共享文件:
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`;
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))`
//! (`app::Message::Page` 是既有通道,不用加新变体)。
//! | 消息 | 做什么 |
//! |---|---|
//! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
//! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 |
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
//!
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的
//! `the_group_dispatch_has_no_catch_all_arm` 会红。
//! ## 为什么「设为默认」不攒到「保存」里
//!
//! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致),
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
//! 界面还标着旧的默认值,只会以为没生效。
//!
//! ## 版本门槛(「为什么选它」那句话的数据)
//!
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
use super::super::*;
use crate::app::diag::diag;
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
impl App {
/// Java 分组的一条消息。
@@ -20,14 +32,261 @@ impl App {
&mut self,
message: pages::settings::java::Message,
) -> Task<Message> {
use pages::settings::java::Message;
match message {
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。
#[cfg(test)]
pages::settings::java::Message::Ping => {
self.view_dirty = true;
Task::none()
Message::Scan => return self.start_java_scan(),
Message::CancelScan => {
self.settings.java.scan.cancel();
self.hydrate_java_scan();
}
Message::AddDraft(value) => {
self.settings.java.add_draft = value;
self.settings.java.notice = None;
self.view_dirty = true;
}
Message::Add => self.add_java_manual(),
Message::SelectRuntime(binary) => self.select_java_default(&binary),
Message::EditMemory(value) => {
self.settings.java.memory = value;
self.touch_java();
}
Message::EditJvm(value) => {
self.settings.java.jvm = value;
self.touch_java();
}
Message::Save => self.save_java_settings(),
}
Task::none()
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
fn touch_java(&mut self) {
self.settings.java.dirty = true;
self.settings.java.notice = None;
self.view_dirty = true;
}
/// 从真值(`config/launcher.json` + 手动名单 + 扫描快照)灌一次状态。
///
/// 什么时候调:① 构造之后(首屏就显示已存的值);② 配置文件被外部改过(`update/clock.rs`
/// 的 mtime 轮询)。**脏草稿不覆盖**(`State::hydrate` 自己判)。
///
/// 首次调用会**自动发起一次扫描**(用户实测的头一条抱怨就是「设置页内并没有自动检测本机已
/// 安装过的 Java 路径」):已经扫完 / 正在扫 / 截图通道都不重扫,所以它只发生一次。
///
/// 截图通道的两档:`ULL_JAVA_STATE=<empty|two|editing>` 用种子(D30 可复现);
/// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
pub(in crate::app) fn hydrate_java_settings(&mut self) {
let seeded = self.options.screenshot.is_some();
let mut live = false;
if seeded {
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
if raw.trim() == "live" {
live = true;
} else {
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
self.settings.java = state;
}
return;
}
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java = pages::settings::java::State::default();
return;
};
let snapshot = pages::settings::java::store::load(&config);
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
let changed = self.settings.java.hydrate(snapshot);
let scan_changed = self.hydrate_java_scan();
self.settings.java.context = self.java_context();
if changed || scan_changed {
self.view_dirty = true;
}
// 自动扫一次(首屏就该有真数据)。`cfg!(test)` 只关掉**这条生产入口的自动发起**
// (单测不该真去查注册表 / 跑 PATH 上的 java);判据与发起本身由
// [`App::auto_scan_java_if_due`] 承载,测试直接调它。
if !cfg!(test) {
self.auto_scan_java_if_due(seeded && !live);
}
}
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
///
/// 单独抽出来是为了可测:`hydrate_java_settings` 的自动发起被 `cfg!(test)` 关掉了,
/// 而「取消过就不再自动扫」这条语义必须有一条能红的用例(r2 复审 P2-2)。
pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool {
let report = self.settings.java.scan.report();
if pages::settings::java::store::auto_scan_due(&report, screenshot) {
let _ = self.start_java_scan();
return true;
}
false
}
/// 把后台扫描的快照灌进状态。返回是否变了。
pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool {
let report = self.settings.java.scan.report();
let changed = self.settings.java.apply_scan(&report);
if changed {
self.view_dirty = true;
}
changed
}
/// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。
fn start_java_scan(&mut self) -> Task<Message> {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return Task::none();
};
let manual = pages::settings::java::store::load_manual(&config)
.into_iter()
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
let roots = pages::settings::java::store::managed_roots(&config);
self.settings.java.notice = None;
if self.settings.java.scan.start(roots, manual, true) {
self.hydrate_java_scan();
}
self.view_dirty = true;
Task::none()
}
/// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。
fn add_java_manual(&mut self) {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
let draft = self.settings.java.add_draft.clone();
match pages::settings::java::store::add_manual(&config, &draft) {
Ok((binary, row)) => {
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
if !self
.settings
.java
.runtimes
.iter()
.any(|known| pages::settings::java::same_path(&known.binary, &binary))
{
self.settings.java.runtimes.push(row);
self.settings
.java
.runtimes
.sort_by_key(|runtime| runtime.major);
}
self.settings.java.add_draft = String::new();
// 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉:
// 否则列表里明明有一行,状态行却写着「还没扫描过」。
self.settings.java.scanned = true;
self.settings.java.notice = Some(Notice::Added(binary));
}
Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)),
}
self.view_dirty = true;
}
/// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。
fn select_java_default(&mut self, binary: &str) {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
match pages::settings::java::store::set_selected(&config, binary) {
Ok(snapshot) => {
// **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM
// 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新
// `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
self.view_dirty = true;
}
/// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。
fn save_java_settings(&mut self) {
let state = &self.settings.java;
if let Err(refusal) = state.validate() {
self.settings.java.notice = Some(Notice::Refused(refusal));
self.view_dirty = true;
return;
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
match pages::settings::java::store::save(&config, &self.settings.java) {
Ok(snapshot) => {
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
self.settings.java.dirty = false;
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::Saved);
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
self.view_dirty = true;
}
/// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。
///
/// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文
/// (页面就不画门槛那一行,也不假装有要求)。
fn java_context(&self) -> Context {
let tiles = self.state.tiles();
let routed = match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => {
tiles.iter().find(|cell| &cell.instance.id == id)
}
_ => None,
};
let Some(cell) = routed.or_else(|| tiles.first()) else {
return Context::default();
};
let instance = cell.instance;
let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version);
Context {
required: Some(Requirement {
instance: instance.name.clone(),
version: instance.mc_version.clone(),
minimum: requirement.minimum,
maximum: requirement.maximum,
}),
layer: Some(match instance.launch.memory_mb {
Some(mb) => Layer::Instance {
instance: instance.name.clone(),
mb,
},
None => Layer::Global,
}),
effective_memory_mb: Some(instance.launch.memory_effective_mb),
}
}
}
/// 写盘失败 → 回执:三档都走 i18n key。
///
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
/// 界面上给一句页面自己的「写不进去」。
fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
use pages::settings::java::store::WriteError;
match error {
WriteError::Refused(refusal) => Notice::Refused(refusal),
WriteError::Unwritable(reason) => Notice::Unwritable(reason),
WriteError::Schema(detail) => {
diag!("[java] 设置写盘失败:{detail}");
Notice::WriteFailed
}
}
}
@@ -256,8 +256,12 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
let mut app = headless();
app.view_dirty = false;
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
let _ = app.update(Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping),
pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory(
"4096".to_string(),
)),
)));
assert!(
@@ -269,7 +273,7 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
// --------------------------------------------------------------- M174:助手配置的活路径
/// 一个指向临时目录的设置桥(真文件,不是内存态)。
fn bridged_app(label: &str) -> (App, std::path::PathBuf) {
pub(super) fn bridged_app(label: &str) -> (App, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!(
"ull-ai-app-{label}-{}-{}",
std::process::id(),
@@ -386,3 +390,7 @@ fn testing_without_configuration_does_nothing() {
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// M175 r1 复审的用例(拆出来守 400 行硬线;`gate::every_source_file_stays_under_the_line_limit` 会红)。
#[path = "tests/java.rs"]
mod java;
@@ -0,0 +1,163 @@
//! M175 的 App 层用例:**「设为默认」不许吃掉未保存的内存 / JVM 草稿**(r1 复审 P2-1)。
//!
//! 为什么单独一个文件:`tests.rs` 加上它之后 446 行,越过 400 行硬线
//! (`app/components/gate.rs` 的 `every_source_file_stays_under_the_line_limit` 会判红)。
use super::bridged_app;
use crate::app::Message;
use crate::pages;
// --------------------------------------------------------------- M175 r1 P2-1:设为默认不许吃草稿
/// 一条 Java 分组消息 → 页面消息。
fn java(message: pages::settings::java::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(message),
))
}
/// 改内存 / JVM 草稿(还没保存)之后去点「设为默认」,**草稿必须还在**,而且仍算未保存。
///
/// 复审 r1 P2-1 的现场:旧代码在 `select_java_default` 里先 `dirty = false` 再 `hydrate`,
/// 于是只写了 `java.selected_path` 的一次操作把用户没保存的内存 / JVM 参数一起冲掉,
/// 一个字节都没落盘、也没有任何提示。
///
/// 变异证明:把 `dirty = false` 那一行加回 `select_java_default` ⇒ 这条红(内存回到 2048、
/// JVM 回到空、`dirty` 变假)。
#[test]
fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() {
let (mut app, path) = bridged_app("java-select");
let _ = app.update(java(pages::settings::java::Message::EditMemory(
"6144".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::EditJvm(
"-XX:+UseG1GC".to_string(),
)));
assert!(app.settings.java.dirty, "改过草稿就该是脏的");
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值还没变");
let _ = app.update(java(pages::settings::java::Message::SelectRuntime(
"C:/jdk-21/bin/java.exe".to_string(),
)));
assert_eq!(
app.settings.java.selected, "C:/jdk-21/bin/java.exe",
"设为默认必须真的写进去"
);
assert_eq!(
app.settings.java.memory, "6144",
"内存草稿被「设为默认」清掉了(P2-1 的数据丢失)"
);
assert_eq!(
app.settings.java.jvm, "-XX:+UseG1GC",
"JVM 草稿被「设为默认」清掉了(P2-1 的数据丢失)"
);
assert!(
app.settings.java.dirty,
"内存 / JVM 参数还没保存,不许标成已保存"
);
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值仍然没变");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来
static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// 一个会一直卡住的扫描作业(直到被取消)。
fn held_job(
_candidates: &[unlucky_core::game::java::staged::Candidate],
cancelled: &dyn Fn() -> bool,
_progress: &mut dyn FnMut(usize, usize),
) -> Vec<pages::settings::java::RuntimeRow> {
while !HOLD.load(std::sync::atomic::Ordering::SeqCst) && !cancelled() {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Vec::new()
}
/// 取消过一次之后,**配置文件再变(心跳重读)也不会把扫描自己拉起来**,状态行仍是「已取消」。
///
/// 现场(r2 复审 P2-2):被取消的扫描 `running=false` / `cancelled=true` / `finished` 仍是
/// `false`,而 `auto_scan_due` 不看 `cancelled` ⇒ `clock.rs` 的 `reload_if_changed` 每变一次
/// `launcher.json`(存内存、改主题、改语言都算)就重新起一个扫描线程,用户的取消被无声撤销。
///
/// 变异证明:把 `auto_scan_due` 里的 `!report.cancelled` 去掉 ⇒ 这里 `auto_scan_java_if_due`
/// 会真的起第二个线程,`scanning` 变 `Some`、状态行不再是「已取消」⇒ 本条红。
#[test]
fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
HOLD.store(false, Ordering::SeqCst);
let (mut app, path) = bridged_app("java-cancel");
// 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成
// cancelled=true / finished=false —— 正是复审指认的那个形状。
let slot = app.settings.java.scan.clone();
assert!(slot.start_with(Vec::new(), Vec::new(), false, held_job));
slot.cancel();
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!slot.report().running, "取消之后线程必须退出");
assert!(slot.report().cancelled);
assert!(!slot.report().finished);
// 心跳那条路:把状态灌一次(`hydrate_java_scan`),再走「该自动扫就扫」的判据。
app.hydrate_java_scan();
assert!(app.settings.java.cancelled, "状态行要留着「已取消」");
assert!(app.settings.java.scanning.is_none());
let started = app.auto_scan_java_if_due(false);
assert!(
!started,
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销)"
);
assert!(
!slot.report().running,
"不许起第二个扫描线程:{:?}",
slot.report()
);
app.hydrate_java_scan();
assert!(
app.settings.java.scanning.is_none(),
"状态行不许回到「正在扫描」"
);
assert!(
app.settings.java.cancelled,
"取消标记要保持到下一次手动扫描为止"
);
HOLD.store(true, Ordering::SeqCst);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P3-3:core 的中文串不许直出
/// 写盘被 schema 拒时,界面拿到的是**页面自己的 key**,而不是 `StoreError` 的中文 `Display`。
///
/// 现场(r2 复审 P3-3):`WriteError::Schema(format!("{error}"))` 里 `core::setting::StoreError`
/// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是
/// `Not saved: IO 失败 @ …`。
///
/// 变异证明:`write_notice` 里把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 本条红。
#[test]
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
let (mut app, path) = bridged_app("java-schema");
// `java.extra_args` 的 schema 是 `Text{max_len: 1024}`:本地校验(内存/引号)不管长度,
// 所以这一条会走到 `store.set` 的 schema 拒绝那一档。
let too_long = "-D".to_string() + &"x".repeat(2000);
let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long)));
let _ = app.update(java(pages::settings::java::Message::Save));
assert_eq!(
app.settings.java.notice,
Some(pages::settings::java::Notice::WriteFailed),
"schema 拒绝要走页面的「写不进去」key,不能把 core 的中文句子原样上屏"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
@@ -8,6 +8,7 @@
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
use crate::app::diag::diag;
use iced::Task;
use unlucky_presenter::dto::AppCommand;
@@ -54,7 +55,7 @@ impl App {
/// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。
fn refuse_busy(&self, what: &str) -> Task<Message> {
eprintln!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
diag!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
Task::none()
}
@@ -64,26 +65,26 @@ impl App {
/// 文件、并把取证进程拖成死循环。
fn restart_now(&mut self) -> Task<Message> {
if self.options.screenshot.is_some() {
eprintln!("[update] 截图通道不重启(忽略「立即重启」)");
diag!("[update] 截图通道不重启(忽略「立即重启」)");
return Task::none();
}
match std::env::current_exe() {
Ok(exe) => {
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
match std::process::Command::new(&exe).args(&args).spawn() {
Ok(child) => eprintln!(
Ok(child) => diag!(
"[update] 已拉起新进程 pid={}:{}",
child.id(),
exe.display()
),
Err(error) => {
eprintln!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
diag!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
return Task::none();
}
}
}
Err(error) => {
eprintln!("[update] 取 current_exe 失败:{error}");
diag!("[update] 取 current_exe 失败:{error}");
return Task::none();
}
}
+7 -1
View File
@@ -75,7 +75,8 @@ impl App {
};
let account = self.rail_account();
match pages::Screen::of(self.state.route()) {
// M187:页面树先立起来,再叠模态层(弹窗画在整窗之上,含标题栏区域)。
let tree: Element<'_, Message> = match pages::Screen::of(self.state.route()) {
Some(_) => shell::view_with_account(
&self.ui,
shell::NavRail {
@@ -124,6 +125,11 @@ impl App {
self.options.force_state,
self.lang(),
),
};
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
match self.dialog_model.as_ref() {
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
None => tree,
}
}
}
+3
View File
@@ -11,6 +11,7 @@
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
//! divider.rs ← COMPBASE(15 §22)
//! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容)
//! motion/ ← M192:动效帧泵(时间轴,不是控件家族;页面经 `pages::render` 用它)
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
//! inputs/ ← 同上(§9/§20/§11/§8)
//! feedback/ ← 同上(§12/§14/§7/§15)
@@ -52,6 +53,8 @@ pub mod gallery;
pub mod inputs;
pub mod interaction;
pub mod layout;
/// M192:动效帧泵(时间轴,不是控件家族)。页面只经 `pages::render` 用它,见模块文档。
pub mod motion;
pub mod navigation;
pub mod scroll;
pub mod selection;
@@ -0,0 +1,182 @@
//! **动效帧泵**(M192):design 的 motion 令牌 → iced 的**有界**帧订阅。
//!
//! ## 这个模块解决什么
//!
//! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 /
//! `MotionScale::NONE` 降级),但适配层从没消费过 ⇒ 所有状态变化都是硬切。本模块是那条缺失的
//! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧**
//! ([`needs_frames`])。
//!
//! ## 有界泵的契约(四条,都有守卫)
//!
//! | 契约 | 为什么 | 守在哪 |
//! |---|---|---|
//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` |
//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) |
//! | **时间轴推进**不密于 [`FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump::tests::frames_are_capped_at_sixty_per_second`、`pump::tests::a_stale_baseline_cannot_kill_the_track_in_one_sample` |
//! | 起跑**必须发生在 `update` 侧**([`observe`]),`view` 侧只采样([`sample`]) | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」 | `app::subscription::tests::a_state_change_opens_the_frame_subscription_in_the_same_tick` |
//!
//! ⚠️ **16 ms 只卡「采样与布局推进」,不卡整窗重绘。** 整窗重绘**跟随渲染器**:
//! 软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈ **290 次整窗重绘** ≈ **0.30 核·秒**),
//! GPU 档被 AutoVsync 限到 40~60 帧/秒。重绘次数是「订阅开着多久」的函数,与闸门无关。
//!
//! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支,
//! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳
//! 天然不会双订阅——它们共享同一个布尔。
//!
//! ## `MotionScale::NONE` 是**真归零**
//!
//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play` 在 `is_off()` 时**不插入轨道**,`progress` 于是
//! 直接返回 `1.0`(到位),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。守卫见
//! `pump::tests::none_scale_never_starts_a_track`。
//!
//! ## 为什么是 thread-local 而不是 `App` 的字段
//!
//! 它是**时间轴**(`(槽 → 进度)` 一张表 + 一个时钟),不是应用状态:没有任何业务语义,也不需要
//! 跨进程持久。iced 在事件循环线程上串行调 `update` / `view`(这与 `App::update(&mut self)` 能存在
//! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程,
//! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。
//!
//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs` 都用了
//! `#[allow(clippy::missing_const_for_thread_local)]`),所以这不是给本 crate 开的新口子。
//!
//! ## 谁在什么时候调
//!
//! | 时机 | 调用 | 在哪 |
//! |---|---|---|
//! | 每条消息处理完(**订阅表求值之前**) | [`observe`]:内容标识变了就起跑 | `App::sync_motion`(`app/update/motion.rs`) |
//! | 每帧画视图 | [`sample`]:只读当前位移 | `pages::render` |
//! | 每帧 | [`advance`]:推进时间轴 | `App::on_frame` |
//!
//! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。
//!
//! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。
//! 不做逐像素 α(D27:CPU 档零运行时半透明)也不做模糊(同上),所以两个渲染后端出图一致。
mod pump;
// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`shifted`,拿不到时长/缓动),
// 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分
// (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。
#[allow(unused_imports)]
pub use pump::{offset, spec, Slot, Spec, FRAME_MS};
use std::cell::{Cell, RefCell};
use std::time::Instant;
use iced::widget::container;
use iced::{Element, Length, Padding};
use unlucky_design::motion::MotionScale;
// clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报
// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等
// 两处已有同样的 `allow`)。初始化确实已是常量,所以按项显式放过(上在宏外面不算,lint 是对
// 展开后的 item 报的)。
thread_local! {
/// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。
#[allow(clippy::missing_const_for_thread_local)]
static PUMP: RefCell<pump::Pump> = const { RefCell::new(pump::Pump::new()) };
/// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。
#[allow(clippy::missing_const_for_thread_local)]
static LAST_FRAME: Cell<Option<Instant>> = const { Cell::new(None) };
}
/// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。
pub fn configure(scale: MotionScale, pin: Option<f64>) {
PUMP.with(|pump| pump.borrow_mut().configure(scale, pin));
}
/// 当前档位。
pub fn scale() -> MotionScale {
PUMP.with(|pump| pump.borrow().scale())
}
/// 已推进的帧数。
pub fn samples() -> u64 {
PUMP.with(|pump| pump.borrow().samples())
}
/// 现在有没有动画在跑(`App::needs_frames` 的一个分支)。
pub fn needs_frames() -> bool {
PUMP.with(|pump| pump.borrow().is_animating())
}
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
///
/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页),
/// `detail` 是详情页的实例 id。
///
/// ## 为什么必须在 update 侧(M192 r1 复审 P1-1)
///
/// iced 0.14 的顺序是「消息 → `update()`(订阅表在**这里**求值)→ `build_user_interfaces`
/// (这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`)。轨道若在 `view` 里插,
/// 同一拍的订阅表看不见它 ⇒ `frames()` 要等下一条消息(最快鼠标 / 最慢 1 Hz 心跳)才打开,
/// 而那一帧的 dt 是整段空闲时长 ⇒ 动画一帧走完,用户只看到一次偏移闪跳。
///
/// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值,
/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。
pub fn observe(screen: Option<u64>, detail: Option<&str>) {
let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, detail));
if started {
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
}
}
/// 当前入场位移(**只采样、无副作用**):`pages::render` 每帧调一次。
pub fn sample() -> (f32, f32) {
PUMP.with(|pump| pump.borrow().offsets())
}
/// 推进时间轴(`App::on_frame` 每帧调一次)。
pub fn advance() {
let now = Instant::now();
let elapsed_ms = LAST_FRAME.with(|last| {
let previous = last.replace(Some(now));
previous.map_or(0.0, |previous| {
now.saturating_duration_since(previous).as_secs_f64() * 1000.0
})
});
PUMP.with(|pump| {
pump.borrow_mut().advance(elapsed_ms);
});
}
/// 把入场位移套到树上。
///
/// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的
/// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套,
/// 动画起止两侧会各重建一次,页面内的滚动位置 / 焦点会被重置(复审 r1 提示的风险)。
/// 常驻一层的代价是布局上多一个 `Fill` 容器(零内距时逐像素相同,出图基线已复验)。
pub fn shifted<'a, M: 'a>(
element: impl Into<Element<'a, M>>,
(dx, dy): (f32, f32),
) -> Element<'a, M> {
container(element.into())
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding {
top: dy,
right: 0.0,
bottom: 0.0,
left: dx,
})
.into()
}
#[cfg(test)]
pub fn reset() {
PUMP.with(|pump| *pump.borrow_mut() = pump::Pump::new());
LAST_FRAME.with(|last| last.set(None));
}
/// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。
#[cfg(test)]
pub fn advance_by(ms: f64) {
PUMP.with(|pump| {
pump.borrow_mut().advance(ms);
});
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,348 @@
//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。
//!
//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际:
//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死,
//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。
//!
//! ## 三个概念
//!
//! | 名字 | 是什么 | 谁定 |
//! |---|---|---|
//! | [`Slot`] | 一个**同屏互斥**的动画位(首屏 / 换页 / 列表↔详情) | 本文件 |
//! | [`Spec`] | 该位的时长档 + 缓动档,**只能**是 design 令牌 | [`spec`](全项目唯一一张表) |
//! | [`Pump`] | 一条时间轴 + 每槽最多一条轨道 | 本文件 |
//!
//! ## 为什么规格表在代码里而不是页面里
//!
//! D31:弹簧/时长令牌是**表**,页面只许按表取、不许自调参数。所以页面拿不到「给我 250ms」这种
//! 口子——它只能说「我要换页了」([`Pump::enter`]),用哪一档由 [`spec`] 决定。改观感只改这张表。
use std::collections::BTreeMap;
use unlucky_design::motion::{DurationToken, Easing, MotionScale};
use unlucky_design::spacing::Space;
/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。
///
/// ⚠️ **这只卡「采样与布局推进」,不卡整窗重绘。** 两个频率必须分开看:
///
/// | 什么 | 频率 | 由谁决定 |
/// |---|---|---|
/// | 时间轴推进 / 位移采样(本常量) | ≤62.5 次/秒 | 这里 |
/// | **整窗重绘** | **跟随渲染器**:软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈**290 次整窗重绘** ≈ **0.30 核·秒**);GPU 档被 AutoVsync 限到 40~60 | `window::frames()` 一订阅,iced_winit 每条消息后都 `request_redraw()`(iced_winit-0.14.1 `src/lib.rs:1222-1240`) |
///
/// 所以「动画期间最多 60fps」只对**采样**成立;重绘次数是订阅时长的函数,与 16 ms 无关。
/// 为什么仍然要 16 ms:每帧重绘已经是既成事实(忙碌态本来就这样),16 ms 卡的是**布局重排**
/// 与 `pages::render` 的重建成本;两者的量级差一个数量级以上(见报告的性能记账)。
pub const FRAME_MS: f64 = 16.0;
/// 单次推进的**最大步进**(4 帧 = 64 ms)。
///
/// 为什么需要它:`advance` 的 dt 来自「上一帧到现在」,而空闲期根本没有帧——醒来的第一帧如果
/// 按真实流逝(秒~分钟级)结算,`duration` 当场就被跨过 ⇒ 到期轨道被立刻删除 ⇒ 动画一帧走完
/// (M192 r1 复审 P1-1 就是这么暴露的)。起点由 `motion::observe` 重置,这里再兜一道:
/// **任何一次采样最多只吃掉 4 帧的时长**,掉帧/被挂起时宁可把动画放慢,也不许把它一次性抹掉。
pub const MAX_STEP_MS: f64 = FRAME_MS * 4.0;
/// 一个动效槽。
///
/// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加
/// (首屏 16 + 换页 12 = 28px)。所以 [`Pump::play`] 起跑新槽时丢掉旧的——语义上也对:
/// 新的一次内容变化取代上一次。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Slot {
/// 首屏进入:进程内只在第一帧跑一次。
Boot,
/// 换页:路由换了(导航轨点选、返回、启动直达)。
Screen,
/// 列表 ↔ 详情:同一页里的子视图切换(实例页点一行)。
Detail,
}
impl Slot {
/// 全部槽(遍历顺序稳定,写日志/断言用)。
pub const ALL: [Slot; 3] = [Slot::Boot, Slot::Screen, Slot::Detail];
/// 稳定名字(日志与测试失败信息用;只有测试在调,故按 `cfg(test)` 编译)。
#[cfg(test)]
pub const fn name(self) -> &'static str {
match self {
Slot::Boot => "boot",
Slot::Screen => "screen",
Slot::Detail => "detail",
}
}
}
/// 一条轨道的规格:**时长档 × 缓动档**,两个都是 design 令牌。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spec {
/// 时长档(毫秒值经 [`MotionScale`] 缩放后生效)。
pub duration: DurationToken,
/// 缓动曲线。
pub easing: Easing,
}
/// 槽 → 规格(**唯一**一张表)。
///
/// 三档时长取自令牌注释里对应场景的那一档(`Medium2` = 导航/tab 切换、`Medium3` = 卡片展开/
/// 列表重排、`Medium4` = 面板开合),曲线**统一用 `Standard`**:
///
/// - 整块页面的位移不是「元素从屏外飞入」,而是「屏内这块内容换了」——M3 把 `Standard` 定义为
/// 「起止都在屏内的元素」,`*Decelerate` 是给「已经在高速运动、要迅速落定」的共享元素用的;
/// - 本地实测(`dev` 机器,见报告):`EmphasizedDecelerate` 把 **75% 的位移压在 51 ms 内**走完
/// (300 ms 档),12px 的整页平移会看起来像「跳一格」;`Standard` 同样时长下是 105 ms,落点更稳。
pub const fn spec(slot: Slot) -> Spec {
match slot {
Slot::Boot => Spec {
duration: DurationToken::Medium4,
easing: Easing::Standard,
},
Slot::Screen => Spec {
duration: DurationToken::Medium2,
easing: Easing::Standard,
},
Slot::Detail => Spec {
duration: DurationToken::Medium3,
easing: Easing::Standard,
},
}
}
/// 槽 → 入场位移(像素;正值 = 从右下方向归位,末态恒为 `(0, 0)`)。
///
/// 幅度取**间距令牌**(4px 栅格)而不是新造一组数字:位移是布局量,和间距用同一把尺子,
/// 将来改栅格不会出现「间距 12、位移 14」这种两套刻度。
pub fn offset(slot: Slot) -> (f32, f32) {
match slot {
Slot::Boot => (0.0, Space::X4.dp()),
Slot::Screen => (0.0, Space::X3.dp()),
Slot::Detail => (Space::X2.dp(), 0.0),
}
}
/// 一条正在跑的轨道。
#[derive(Debug, Clone, Copy)]
struct Track {
/// 起跑时刻(动画时间轴的坐标,毫秒)。
start_ms: f64,
spec: Spec,
}
/// 帧泵:一条时间轴 + 每槽一条轨道。
///
/// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而
/// `pages_demo` 这类截图 harness 从不打开——D30 的「同一份代码出同一张图」因此不受动效影响。
#[derive(Debug)]
pub struct Pump {
scale: MotionScale,
/// `Some(ms)` = 钉住相位(多帧序列图取证;见 `ULL_MOTION_AT`)。
pin: Option<f64>,
now_ms: f64,
pending_ms: f64,
tracks: BTreeMap<Slot, Track>,
screen: Option<u64>,
detail: Option<String>,
painted: bool,
samples: u64,
}
impl Pump {
/// 出厂态:关着、没跑过、没有轨道。
pub const fn new() -> Self {
Self {
scale: MotionScale::NONE,
pin: None,
now_ms: 0.0,
pending_ms: 0.0,
tracks: BTreeMap::new(),
screen: None,
detail: None,
painted: false,
samples: 0,
}
}
/// 定档(启动一拍调一次)。
///
/// 顺带**重置内容标识**:iced 会在处理 `Boot` 之前先问一次 `view()`(实测:那一帧早于
/// `on_boot` 的日志),于是「第一帧」发生在档位还没定下来的时候。不重置的话首屏入场就被那次
/// 预渲染吃掉了(`painted = true`),之后标识再没变过 ⇒ Boot 槽永远不跑。
pub fn configure(&mut self, scale: MotionScale, pin: Option<f64>) {
self.scale = scale;
self.pin = pin;
self.painted = false;
self.screen = None;
self.detail = None;
self.pending_ms = 0.0;
if scale.is_off() {
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
self.tracks.clear();
}
}
/// 当前档位。
pub fn scale(&self) -> MotionScale {
self.scale
}
/// 已推进的帧数(守卫与 `ULL_TICK_TRACE` 用)。
pub fn samples(&self) -> u64 {
self.samples
}
/// 时间轴(毫秒)。
#[cfg(test)]
pub fn now_ms(&self) -> f64 {
self.now_ms
}
/// 现在有没有动画在跑(= 要不要订阅帧)。
pub fn is_animating(&self) -> bool {
!self.tracks.is_empty()
}
/// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。返回 `true` = 真的起跑了。
///
/// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 =
/// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。
pub fn play(&mut self, slot: Slot) -> bool {
self.tracks.clear();
if self.scale.is_off() {
return false;
}
// 钉住相位时轨道的起点固定在 0:进度于是恒等于「钉住的毫秒 / 时长」,
// 与「谁在第几帧起跑」无关——这正是多帧序列图要的可复现性。
let start_ms = if self.pin.is_some() { 0.0 } else { self.now_ms };
self.tracks.insert(
slot,
Track {
start_ms,
spec: spec(slot),
},
);
true
}
/// 采样:`0.0` = 刚起跑,`1.0` = 到位(**没有轨道就是到位**)。
pub fn progress(&self, slot: Slot) -> f32 {
let Some(track) = self.tracks.get(&slot) else {
return 1.0;
};
let duration = f64::from(self.scale.ms(track.spec.duration));
if duration <= 0.0 {
return 1.0;
}
let elapsed = self.elapsed_ms() - track.start_ms;
let t = (elapsed / duration).clamp(0.0, 1.0) as f32;
track.spec.easing.sample(t)
}
/// 当前动画时刻(钉住相位时恒为钉住值)。
fn elapsed_ms(&self) -> f64 {
self.pin.unwrap_or(self.now_ms)
}
/// 页主体的内容**标识**变了就起跑对应槽;**必须在 update 侧调**(见 `App::sync_motion`)。
///
/// ## 为什么不能放在 `view` 里(M192 r1 复审 P1-1,这是本轮最重要的一行注释)
///
/// iced 0.14 的顺序是「消息 → `update()`(**`program.subscription()` 在这里求值**)→
/// `build_user_interfaces`(这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`,
/// 订阅表在 `update()` 内部的 `lib.rs:1338` 求值)。所以在 `view` 里插轨道,同一拍的订阅表
/// **看不见**它,`frames()` 要等下一条消息(最快鼠标、最慢 1 Hz 心跳)才打开——那一段里
/// 页面已经带着位移画过一帧,然后就没了:用户看到的是一次偏移闪跳。
///
/// 三条判据(顺序即优先级):
/// 1. 第一次看到**归 pages 画的屏** → [`Slot::Boot`];
/// 2. 屏变了 → [`Slot::Screen`];
/// 3. 屏没变但详情变了 → [`Slot::Detail`]。
///
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**——
/// 否则会白跑 300 ms 的帧却没人采样它;而「从这些页回到某一屏」因为标识从 `None` 变回
/// `Some`,仍然会跑换页那一档。
///
/// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。
pub fn observe(&mut self, screen: Option<u64>, detail: Option<&str>) -> bool {
let mut started = false;
if !self.painted {
self.painted = true;
if screen.is_some() {
started = self.play(Slot::Boot);
}
} else if self.screen != screen {
if screen.is_some() {
started = self.play(Slot::Screen);
}
} else if self.detail.as_deref() != detail && screen.is_some() {
started = self.play(Slot::Detail);
}
self.screen = screen;
if self.detail.as_deref() != detail {
self.detail = detail.map(str::to_owned);
}
started
}
/// 页主体的入场位移(**只采样,无副作用**):`pages::render` 每帧调一次。
///
/// 三个槽的剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚;末帧全部 `1.0`
/// ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。
pub fn offsets(&self) -> (f32, f32) {
let mut dx = 0.0_f32;
let mut dy = 0.0_f32;
for slot in Slot::ALL {
let remaining = 1.0 - self.progress(slot);
let (ox, oy) = offset(slot);
dx += ox * remaining;
dy += oy * remaining;
}
(dx.round(), dy.round())
}
/// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
///
/// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 /
/// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」
/// 的唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表
/// 里就不再有 `window::frames()`)④钉住相位时时间轴不前进(取证专用)。
pub fn advance(&mut self, dt_ms: f64) -> bool {
if self.tracks.is_empty() {
self.pending_ms = 0.0;
return false;
}
if dt_ms.is_finite() && dt_ms > 0.0 {
// ②:上限不是「省事」,是正确性——stale 的帧基准(空闲整段)会把轨道一帧杀完。
self.pending_ms += dt_ms.min(MAX_STEP_MS);
}
if self.pending_ms < FRAME_MS {
return false;
}
let step = std::mem::take(&mut self.pending_ms);
if self.pin.is_none() {
self.now_ms += step;
}
self.samples += 1;
let now = self.elapsed_ms();
let finished: Vec<Slot> = self
.tracks
.iter()
.filter(|(_, track)| {
let duration = f64::from(self.scale.ms(track.spec.duration));
duration <= 0.0 || now - track.start_ms >= duration
})
.map(|(slot, _)| *slot)
.collect();
for slot in finished {
self.tracks.remove(&slot);
}
true
}
}
impl Default for Pump {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,345 @@
//! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。
//!
//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs` 的 `mod tests`:这一组只管算术。
use super::pump::{offset, spec, Pump, Slot, FRAME_MS, MAX_STEP_MS};
use super::*;
/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。
fn standard() -> Pump {
let mut pump = Pump::new();
pump.configure(MotionScale::STANDARD, None);
pump
}
/// 按「每帧 16 ms」推进到全部轨道结束(上限 20 s,防死循环)。
fn settle(pump: &mut Pump) -> u64 {
let mut frames = 0;
for _ in 0..1250 {
if !pump.is_animating() {
break;
}
if pump.advance(FRAME_MS) {
frames += 1;
}
}
frames
}
// ------------------------------------------------------------------ 令牌表
/// 规格**全部**来自 design 的令牌表,且落在 M3 的常规区间(改表会让这条红)。
#[test]
fn every_slot_spec_is_a_registered_token_pair() {
for slot in Slot::ALL {
let spec = spec(slot);
let ms = spec.duration.ms();
assert!(
(250..=500).contains(&ms),
"{}: 时长 {}ms 不在换页类动效的合理区间(克制优先)",
slot.name(),
ms
);
// 缓动必须是真的曲线(端点 = 0/1)
assert!(spec.easing.sample(0.0).abs() < 1.0e-6);
assert!((spec.easing.sample(1.0) - 1.0).abs() < 1.0e-6);
// 位移必须来自间距栅格(4 的倍数),且不为零
let (dx, dy) = offset(slot);
assert!(
dx > 0.0 || dy > 0.0,
"{}: 位移为 0 就没有入场感",
slot.name()
);
assert_eq!(dx % 4.0, 0.0);
assert_eq!(dy % 4.0, 0.0);
assert!(dx.max(dy) <= 24.0, "{}: 位移过大(克制优先)", slot.name());
}
// 表本身钉住:谁改了这三档,报告与出图都要跟着改
assert_eq!(spec(Slot::Screen).duration.ms(), 300);
assert_eq!(spec(Slot::Detail).duration.ms(), 350);
assert_eq!(spec(Slot::Boot).duration.ms(), 400);
}
// ------------------------------------------------------------------ 有界泵
/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。
///
/// 抓得住:把 `advance` 里的「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订),或把
/// `is_animating` 改成恒真。两条都让本用例红。
#[test]
fn a_finished_animation_stops_asking_for_frames() {
let mut pump = standard();
assert!(pump.play(Slot::Screen), "起跑必须成功");
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
settle(&mut pump);
assert!(
!pump.is_animating(),
"动画结束必须**当场**退订(占着轨道 = 空闲烧帧)"
);
assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位");
assert_eq!(pump.offsets(), (0.0, 0.0), "结束即归位");
// 再推进也不该有任何采样(订阅表里已经没有帧了)
let before = pump.samples();
for _ in 0..60 {
assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false");
}
assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生");
}
/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。
///
/// 注意这条**不管整窗重绘**:重绘跟随渲染器(见 [`FRAME_MS`] 文档)。
/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。
#[test]
fn frames_are_capped_at_sixty_per_second() {
let mut pump = standard();
pump.play(Slot::Screen);
// 200 帧、每帧 1 ms(模拟 1000 fps 的渲染器)
let mut advanced = 0;
for _ in 0..200 {
if pump.advance(1.0) {
advanced += 1;
}
}
assert!(
advanced <= 13,
"200 ms 内最多 13 次推进(200/16),实测 {advanced} 次"
);
assert!(
advanced >= 11,
"也别少得离谱(掉帧会看起来卡),实测 {advanced}"
);
assert_eq!(pump.samples(), advanced);
// 时间轴按真实时间走,不受「帧被合并」影响
assert!(
(pump.now_ms() - 192.0).abs() <= 16.0,
"时间轴应约 192 ms(12 × 16),实测 {}",
pump.now_ms()
);
}
/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。
///
/// 运行时真出现过:空闲几分钟后第一条消息起跑轨道,第一帧的 dt 是整段空闲时长 ⇒ 立即到期 ⇒
/// 动画一帧走完,用户只看到一次偏移闪跳。两道兜底:起跑时重置帧基准(`motion::observe`)与
/// 单次最大步进 [`MAX_STEP_MS`];这条守的是第二道。
#[test]
fn a_stale_baseline_cannot_kill_the_track_in_one_sample() {
let mut pump = standard();
pump.play(Slot::Screen);
// 60 秒的「空闲间隔」当 dt
assert!(pump.advance(60_000.0));
assert!(
pump.is_animating(),
"一次超长 dt 不许把轨道当场删掉(那就等于没有动画)"
);
assert!(
pump.now_ms() <= MAX_STEP_MS + 1.0e-6,
"单次推进最多 {:?} ms,实测 {}",
MAX_STEP_MS,
pump.now_ms()
);
assert!(
pump.progress(Slot::Screen) < 1.0,
"进度不许一步到位(那就是闪跳)"
);
// 正常帧间隔照样把动画跑完(没被抖动卡死)
settle(&mut pump);
assert!(!pump.is_animating());
}
/// 采样必须单调、且末尾回到 1.0(回弹类曲线不在本批,见 [`spec`] 的表)。
#[test]
fn progress_is_monotone_and_lands_on_one() {
let mut pump = standard();
pump.play(Slot::Detail);
let mut previous = 0.0;
for _ in 0..40 {
let value = pump.progress(Slot::Detail);
assert!(value >= previous, "进度倒退了:{previous} → {value}");
previous = value;
if !pump.advance(FRAME_MS) {
break;
}
}
assert_eq!(previous, 1.0, "末帧必须到位");
}
// ------------------------------------------------------------------ NONE 降级
/// **`MotionScale::NONE` 是真归零**:不起轨道、不采样、位移恒为 0。
///
/// 抓得住:把 `play` 里的 `is_off()` 分支删掉(变成「跑一遍 0 时长动画」)——那样这个用例里
/// `is_animating()` 会短暂为真、`samples` 会增长,帧订阅次数就不是 0 了。
#[test]
fn none_scale_never_starts_a_track() {
let mut pump = Pump::new(); // 出厂即 NONE
assert!(pump.scale().is_off());
// 首屏 + 换页 + 换详情:三次状态变化都不许留下轨道
assert!(!pump.observe(Some(1), None), "关掉动效时不许起跑");
assert_eq!(pump.offsets(), (0.0, 0.0), "关掉动效必须直接落终态");
assert!(!pump.is_animating());
assert!(!pump.observe(Some(2), None));
assert_eq!(pump.offsets(), (0.0, 0.0));
assert!(!pump.observe(Some(2), Some("instance-7")));
assert_eq!(pump.offsets(), (0.0, 0.0));
assert!(!pump.is_animating());
for _ in 0..120 {
assert!(!pump.advance(FRAME_MS), "关掉动效后没有任何可推进的");
}
assert_eq!(pump.samples(), 0, "NONE 档的帧订阅次数必须是 0");
}
/// 运行期把关掉动效(`configure(NONE)`)也要立刻落终态,而不是把在跑的动画「缩到 0 时长」。
#[test]
fn switching_to_none_mid_flight_settles_immediately() {
let mut pump = standard();
pump.play(Slot::Screen);
pump.advance(FRAME_MS);
assert!(pump.is_animating());
pump.configure(MotionScale::NONE, None);
assert!(!pump.is_animating(), "关掉动效必须当场清空轨道");
assert_eq!(pump.progress(Slot::Screen), 1.0);
assert_eq!(pump.offsets(), (0.0, 0.0));
}
/// 半速档(CPU 渲染兜底)真的把时长减半:同样的时间轴推进,轨道更早结束。
#[test]
fn half_scale_finishes_in_half_the_frames() {
let mut full = standard();
full.play(Slot::Screen);
let full_frames = settle(&mut full);
let mut half = Pump::new();
half.configure(MotionScale::HALF, None);
half.play(Slot::Screen);
let half_frames = settle(&mut half);
assert!(half_frames > 0 && full_frames > 0);
assert!(
half_frames * 2 <= full_frames + 2,
"半速档应约用一半帧数:full={full_frames} half={half_frames}"
);
}
// ------------------------------------------------------------------ 触发判据
/// 三个槽的触发优先级:首屏 Boot → 换屏 Screen → 同屏换详情 Detail。
#[test]
fn identity_changes_pick_the_right_slot() {
let mut pump = standard();
// 首帧:Boot(位移是纵向 16)
assert!(pump.observe(Some(1), None), "首帧要跑 Boot 槽");
let (dx, dy) = pump.offsets();
assert_eq!(dx, 0.0);
assert_eq!(dy, offset(Slot::Boot).1, "首屏应有一个向下的入场位移");
assert_eq!(
pump.progress(Slot::Screen),
1.0,
"首帧不该顺带起跑 Screen 槽"
);
settle(&mut pump);
assert!(!pump.observe(Some(1), None), "标识没变 ⇒ 不起跑");
assert_eq!(pump.offsets(), (0.0, 0.0));
// 换屏:Screen(纵向)
assert!(pump.observe(Some(2), None));
let (dx, dy) = pump.offsets();
assert_eq!(dx, 0.0);
assert_eq!(dy, offset(Slot::Screen).1);
assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽");
settle(&mut pump);
// 同屏换详情:Detail(横向)
assert!(pump.observe(Some(2), Some("instance-7")));
let (dx, dy) = pump.offsets();
assert_eq!(dx, offset(Slot::Detail).0, "列表↔详情是横向入场");
assert_eq!(dy, 0.0);
assert!(pump.progress(Slot::Detail) < 1.0);
settle(&mut pump);
assert!(!pump.observe(Some(2), Some("instance-7")));
assert_eq!(pump.offsets(), (0.0, 0.0));
}
/// 不归 pages 画的屏(账户页 / AI 独立页,`screen == None`)**不起跑**;回到某一屏照样跑。
#[test]
fn screens_rendered_elsewhere_never_start_a_track() {
let mut pump = standard();
assert!(pump.observe(Some(1), None), "首屏开机跑 Boot");
settle(&mut pump);
assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧");
assert!(!pump.is_animating());
assert_eq!(pump.offsets(), (0.0, 0.0));
assert!(
pump.observe(Some(1), None),
"从账户页回到某一屏仍然是换页,要跑"
);
assert_eq!(pump.offsets().1, offset(Slot::Screen).1);
}
/// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。
#[test]
fn a_new_transition_supersedes_the_previous_one() {
let mut pump = standard();
assert_eq!(
pump.offsets().1,
0.0,
"还没 observe 时没有位移(采样是纯函数)"
);
pump.observe(Some(1), None);
assert_eq!(pump.offsets().1, offset(Slot::Boot).1);
// 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加
pump.observe(Some(2), None);
assert_eq!(
pump.offsets().1,
offset(Slot::Screen).1,
"新的一次内容变化应取代上一次(叠加会让位移变成两档之和)"
);
}
/// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。
#[test]
fn repeated_paints_never_restart_the_animation() {
let mut pump = standard();
pump.observe(Some(3), Some("instance-9"));
let frames = settle(&mut pump);
assert!(frames > 0);
for _ in 0..30 {
assert!(!pump.observe(Some(3), Some("instance-9")));
assert_eq!(pump.offsets(), (0.0, 0.0));
}
assert!(!pump.is_animating(), "重复绘制不该起新轨道");
}
// ------------------------------------------------------------------ 取证相位
/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。
#[test]
fn pinned_phase_freezes_the_clock() {
let mut pump = Pump::new();
pump.configure(MotionScale::STANDARD, Some(150.0));
pump.play(Slot::Screen); // 300 ms 档 ⇒ 钉在 150 = 一半
for _ in 0..10 {
pump.advance(FRAME_MS);
}
let progress = pump.progress(Slot::Screen);
assert!(
(progress - spec(Slot::Screen).easing.sample(0.5)).abs() < 1.0e-6,
"钉在 150/300 的进度应等于缓动曲线的中点,实测 {progress}"
);
assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)");
}
@@ -56,6 +56,22 @@ impl<'a, M> Chip<'a, M> {
Self::new(label, ChipVariant::Input, false, on_press)
}
/// **纯展示**的 filter chip(选中态、无消息 ⇒ 永远不可交互)。
///
/// 与 `filter(label, true, msg).disabled()` 等价,但**不要求调用方编一条永远收不到的消息**:
/// 本轮 Java 页那颗「默认」标记就是这么用的(`Message::Noop` 已删,别再拿一条真消息当占位)。
pub fn badge(label: &'a str) -> Self {
Self {
label,
variant: ChipVariant::Filter,
selected: true,
leading: None,
trailing: None,
on_press: None,
state: None,
}
}
/// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。
///
/// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的
+3
View File
@@ -38,6 +38,9 @@ pub mod widgets;
#[cfg(test)]
mod tests;
#[cfg(test)]
#[path = "account/tests_msa.rs"]
mod tests_msa;
#[cfg(test)]
#[path = "account/tests_text.rs"]
mod tests_text;
+88 -55
View File
@@ -2,9 +2,15 @@
//!
//! ## 为什么不是「一个转圈」
//!
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
//! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。
//!
//! ## 文案一律**借用模型里的串**(M178)
//!
//! 倒计时/回退说明这类运行时格式化出来的句子由 [`super::panels`] 在模型层拼好(带 i18n),
//! 视图只拿 `&'a str` 画。为什么不在这里 `format!` 后交给 `frame::text_role_owned`:
//! 那条 owned 路径上的**中文画不出来**(实测:同一位置 ASCII 能画、`剩 15:00` 一片空白),
//! 而借用路径正常。模型持有字符串、视图借用,两个问题一起消掉。
use iced::widget::{column, row};
use iced::{Alignment, Element};
@@ -13,7 +19,7 @@ use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::LoginFailure;
use unlucky_presenter::dto::{BrowserLoginNote, LoginFailure};
use crate::app::components::buttons::{self, Button};
use crate::pages::frame;
@@ -36,34 +42,40 @@ pub fn panel<'a>(
DevicePanel::Code {
code,
link,
remaining,
countdown,
expired,
} => device_code(ui, code, link, *remaining, *expired, catalog),
note,
} => device_code(ui, code, link, countdown, *expired, *note, catalog),
DevicePanel::Browser {
link,
remaining,
countdown,
expired,
} => browser(ui, link, *remaining, *expired, catalog),
DevicePanel::Failed { failure } => failed(ui, *failure, catalog),
} => browser(ui, link, countdown, *expired, catalog),
DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, catalog),
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
}
}
/// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。
/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
/// 没登记时在预检处直接回退设备码)。
///
/// 文案(复审 r1 P2-5):主按钮写「使用 Microsoft 账户登录」而不是「改用设备码」;
/// 「没有浏览器时用这个」放在按钮下面一行说明里。
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![row![
buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountDeviceBrowser),
Account::StartBrowser.into_page(),
catalog.text(Key::AccountDeviceSignIn),
Account::StartDevice.into_page(),
)
),
buttons::outlined(
ui,
Button::new(
catalog.text(Key::AccountDeviceUseCode),
Account::StartDevice.into_page(),
catalog.text(Key::AccountDeviceBrowser),
Account::StartBrowser.into_page(),
)
),
buttons::text(
@@ -76,7 +88,14 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),]
.spacing(ui.space(Space::X3));
.spacing(ui.space(Space::X3))
.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountDeviceFallbackHint),
ui.color(Role::OnSurfaceVariant),
));
frame::card(ui, body)
}
@@ -105,13 +124,14 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
frame::card(ui, body)
}
/// 设备码等待态:**码 + 链接 + 倒计时**。
/// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。
fn device_code<'a>(
ui: &'a Ui,
code: &'a str,
link: &'a str,
remaining: Option<u64>,
countdown: &'a str,
expired: bool,
note: Option<BrowserLoginNote>,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let mut actions = row![
@@ -150,9 +170,7 @@ fn device_code<'a>(
),
));
let countdown = countdown_line(ui, remaining, expired, catalog);
let body = column![
let mut body = column![
frame::text_role(
ui,
TypeRole::LabelMedium,
@@ -174,7 +192,7 @@ fn device_code<'a>(
link,
ui.color(Role::OnSurfaceVariant),
),
countdown,
countdown_line(ui, countdown, expired),
frame::text_role(
ui,
TypeRole::BodySmall,
@@ -182,17 +200,21 @@ fn device_code<'a>(
catalog.text(Key::AccountDeviceHint),
ui.color(Role::OnSurfaceVariant),
),
actions,
]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
// 回退说明放在**动作行之前**:它解释的是「这个面板为什么是设备码」,
// 用户读完再决定复制码/取消(放在按钮下面会像脚注,读不到)。
if let Some(text) = note_text(ui, note, catalog) {
body = body.push(text);
}
frame::card(ui, body.push(actions))
}
/// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。
fn browser<'a>(
ui: &'a Ui,
link: &'a str,
remaining: Option<u64>,
countdown: &'a str,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
@@ -237,37 +259,19 @@ fn browser<'a>(
link,
ui.color(Role::OnSurfaceVariant),
),
countdown_line(ui, remaining, expired, catalog),
countdown_line(ui, countdown, expired),
actions,
]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。
fn countdown_line<'a>(
ui: &'a Ui,
remaining: Option<u64>,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**;文案由模型拼好,这里只定颜色与强调)。
fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> {
if expired {
return frame::text_role(
ui,
TypeRole::LabelMedium,
true,
catalog.text(Key::AccountDeviceExpired),
ui.color(Role::Error),
);
return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error));
}
let text = remaining.map_or_else(String::new, |secs| {
// mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式
// (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
});
// 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`)
frame::text_role_owned(
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
@@ -276,16 +280,45 @@ fn countdown_line<'a>(
)
}
/// 回退说明的**唯一文案出口**(视图与页面级断言共用:断言取样走这里,才是「视图会画的那句」)。
pub(super) fn browser_note_text(note: BrowserLoginNote, catalog: Catalog) -> &'static str {
catalog.text(super::model::note_key(note))
}
/// 回退说明那一行(`None` = 没有这回事)。
fn note_text<'a>(
ui: &'a Ui,
note: Option<BrowserLoginNote>,
catalog: Catalog,
) -> Option<Element<'a, PageMessage>> {
let note = note?;
// 用正文色而不是 `Error`:这不是失败态(设备码接着跑),画成红色会让人以为登录已经挂了。
Some(frame::text_role(
ui,
TypeRole::BodySmall,
false,
browser_note_text(note, catalog),
ui.color(Role::OnSurfaceVariant),
))
}
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
///
/// 三点修订叠在这里:
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小);
/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。
fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> {
let body = catalog.text(failure_body_key(failure)).to_string();
/// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
/// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调;
/// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」;
/// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
fn failed<'a>(
ui: &'a Ui,
failure: LoginFailure,
note: Option<BrowserLoginNote>,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let mut body = catalog.text(failure_body_key(failure)).to_string();
if let Some(note) = note {
body.push_str("\n\n");
body.push_str(browser_note_text(note, catalog));
}
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
if !matches!(failure, LoginFailure::MsaNotConfigured) {
+17 -2
View File
@@ -7,7 +7,9 @@
use iced::widget::image;
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView};
use unlucky_presenter::dto::{
AccountKind, AccountNotice, AccountNoticeCode, AccountView, BrowserLoginNote,
};
use unlucky_presenter::state::AppState;
use super::avatar::{Avatar, Cache};
@@ -164,6 +166,18 @@ impl Form {
}
}
/// 回退说明码 → i18n key(**唯一的「码→文案」映射点**;原文永远不上屏)。
///
/// 与 [`Track::title_key`] 同一种写法:页面只认 key,后端只给码,两边独立演进。
#[must_use]
pub const fn note_key(note: BrowserLoginNote) -> Key {
match note {
BrowserLoginNote::RedirectNotRegistered => Key::AccountBrowserFallbackNotRegistered,
BrowserLoginNote::PreflightUnavailable => Key::AccountBrowserFallbackUnverified,
BrowserLoginNote::AttemptFailed => Key::AccountBrowserFallbackFailed,
}
}
/// 列表行 / 当前账户卡片用的数据。
#[derive(Debug, Clone, PartialEq)]
pub struct Row {
@@ -253,7 +267,8 @@ impl Page {
.map(|account| row_of(account, avatars, lang))
.collect();
let active = rows.iter().position(|row| row.active);
let device = device_panel(&state.device_login, state.device_code_remaining());
let remaining = state.device_code_remaining();
let device = device_panel(&state.device_login, remaining, lang.catalog());
// 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、
// 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。
let panel = if matches!(device, DevicePanel::Hidden) {
+40 -9
View File
@@ -10,7 +10,10 @@
//! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。
//! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。
use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure};
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
AuthlibLoginView, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure,
};
/// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -25,17 +28,23 @@ pub enum DevicePanel {
code: String,
/// 验证链接
link: String,
/// 剩余秒数
remaining: Option<u64>,
/// 是否已过期
/// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」)
///
/// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径
/// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、
/// 视图用 `&'a str` 借用,两边都不依赖那条路径。
countdown: String,
/// 是否已过期(决定颜色与「重试」按钮)
expired: bool,
/// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事)
note: Option<BrowserLoginNote>,
},
/// 授权码流:等浏览器回调
Browser {
/// 授权链接(可复制)
link: String,
/// 剩余秒数
remaining: Option<u64>,
/// 倒计时那行已渲染好的文案(同上)
countdown: String,
/// 是否已过期
expired: bool,
},
@@ -43,6 +52,8 @@ pub enum DevicePanel {
Failed {
/// 原因码(页面据此选 i18n)
failure: LoginFailure,
/// 回退说明(浏览器登录没走成之后设备码也失败了;M178)
note: Option<BrowserLoginNote>,
},
/// 成功
Succeeded {
@@ -76,8 +87,13 @@ pub enum AuthlibPanel {
}
/// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。
pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> DevicePanel {
pub(super) fn device_panel(
view: &DeviceLoginView,
remaining: Option<u64>,
catalog: Catalog,
) -> DevicePanel {
let expired = remaining == Some(0);
let countdown = countdown_text(remaining, expired, catalog);
match view.phase {
DeviceLoginPhase::Idle => DevicePanel::Hidden,
DeviceLoginPhase::Requesting => DevicePanel::Requesting,
@@ -85,20 +101,22 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
if view.user_code.trim().is_empty() {
DevicePanel::Browser {
link: view.verification_uri.clone(),
remaining,
countdown,
expired,
}
} else {
DevicePanel::Code {
code: view.user_code.clone(),
link: view.qr_link.clone(),
remaining,
countdown,
expired,
note: view.browser_note,
}
}
}
DeviceLoginPhase::Failed => DevicePanel::Failed {
failure: view.failure.unwrap_or(LoginFailure::Other),
note: view.browser_note,
},
DeviceLoginPhase::Succeeded => DevicePanel::Succeeded {
name: view.account_name.clone(),
@@ -106,6 +124,19 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
}
}
/// 倒计时那一行的文案(`mm:ss` + i18n;过期/不知道有效期的两种情况都在这里收口)。
///
/// `mm:ss` 而不是 `900s`:人读的是分秒;数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog。
fn countdown_text(remaining: Option<u64>, expired: bool, catalog: Catalog) -> String {
if expired {
return catalog.text(Key::AccountDeviceExpired).to_string();
}
remaining.map_or_else(String::new, |secs| {
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
})
}
/// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。
pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) {
match view.phase {
+8 -5
View File
@@ -33,7 +33,7 @@ fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
}
}
fn build(state: &AppState, form: &Form) -> Page {
pub(super) fn build(state: &AppState, form: &Form) -> Page {
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
@@ -212,12 +212,15 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
match &page.device {
DevicePanel::Code {
code,
remaining,
countdown,
expired,
..
} => {
assert_eq!(code, "ABCD-EFGH");
assert_eq!(*remaining, Some(900));
assert!(
countdown.contains("15:00"),
"倒计时要画成 mm:ss:{countdown}"
);
assert!(!expired);
}
other => panic!("等待态应该是设备码面板,实际 {other:?}"),
@@ -232,10 +235,10 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Code {
remaining, expired, ..
countdown, expired, ..
} => {
assert_eq!(*remaining, Some(0));
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行");
}
other => panic!("过期态仍是设备码面板,实际 {other:?}"),
}
@@ -0,0 +1,126 @@
//! M178 的登录回退判据(从 `tests.rs` 拆出来守 400 行硬线)。
//!
//! 这两条钉的是**页面这一半**:浏览器登录没走成、已回退设备码时,用户看到的是
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
};
use unlucky_presenter::state::AppState;
use super::avatar::Cache;
use super::login::browser_note_text;
use super::model::{DevicePanel, Form, Page};
use super::tests::build;
use super::tests_text::{has_cjk, on_screen};
/// 中文界面的同一页(`build` 钉死英文,这里要中文那一套)。
fn build_zh(state: &AppState, form: &Form) -> Page {
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
}
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
///
/// 抓得住:`browser_note` 在 `device_panel` 里被丢掉(面板就只剩用户码,用户不知道为什么
/// 变成了设备码)、或有人把 presenter 的原始诊断串(`invalid_request ...`)画上屏。
/// 抓不住:这句提示在 1180 宽下会不会换行难看(那靠出图给 tower 看)。
#[test]
fn a_browser_fallback_shows_a_readable_note_not_the_raw_aad_error() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
message: "invalid_request: The provided value for the input parameter 'redirect_uri' \
is not valid. The expected value is a URI which matches a redirect URI registered for this client \
application."
.to_string(),
..DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "")
}
.with_browser_note(BrowserLoginNote::RedirectNotRegistered),
)));
let page = build(&state, &Form::new());
// 1. 面板态带着说明码(不是只剩一个用户码)
match &page.device {
DevicePanel::Code { note, code, .. } => {
assert_eq!(*note, Some(BrowserLoginNote::RedirectNotRegistered));
assert_eq!(code, "ABCD-EFGH");
}
other => panic!("回退后仍是设备码面板,实际 {other:?}"),
}
// 2. 英文界面上画出来的那一段:是 i18n 文案、含可照抄的修法,且没有 AAD 原始报文/中文
let text = on_screen(&page);
assert!(!has_cjk(&text), "英文界面不许出现中文:{text}");
assert!(
text.contains("http://localhost/auth"),
"提示里要给出可照抄的重定向 URI:{text}"
);
assert!(
text.to_lowercase().contains("redirect uri"),
"提示里要点名「重定向 URI」这一项:{text}"
);
assert!(
!text.contains("invalid_request"),
"原始报文不许上屏:{text}"
);
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
// 3. 中文界面同样走一遍(复审 r1 P2-4):文案来自**视图自己的出口**
// `login::browser_note_text`,中文要有、可照抄的修法要有、AAD 原始码不许有。
let zh = build_zh(&state, &Form::new());
let zh_text = on_screen(&zh);
assert!(has_cjk(&zh_text), "中文界面要真有中文:{zh_text}");
let note_text = browser_note_text(
BrowserLoginNote::RedirectNotRegistered,
Lang::ZhCn.catalog(),
);
assert!(
note_text.contains("http://localhost/auth"),
"中文文案也要给出可照抄的重定向 URI:{note_text}"
);
assert!(note_text.contains("重定向 URI"), "{note_text}");
assert!(note_text.contains("设备码"), "{note_text}");
for forbidden in ["invalid_request", "AADSTS", "is not valid", "{"] {
assert!(
!zh_text.contains(forbidden),
"中文界面混进了原始报文:{zh_text}"
);
assert!(
!note_text.contains(forbidden),
"词条混进了原始报文:{note_text}"
);
}
// 视图那一行的来源就是这一条文案:取样函数与视图共用同一个出口(不是测试另写映射)。
assert_eq!(
browser_note_text(BrowserLoginNote::PreflightUnavailable, Lang::En.catalog()),
"Could not confirm that browser sign-in is usable, so it switched to a device code."
);
}
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
#[test]
fn a_failed_device_login_keeps_the_fallback_note() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::failed_as(
LoginFailure::Unreachable,
"连不上 login.microsoftonline.com",
)
.with_browser_note(BrowserLoginNote::PreflightUnavailable),
)));
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Failed { failure, note } => {
assert_eq!(*failure, LoginFailure::Unreachable);
assert_eq!(*note, Some(BrowserLoginNote::PreflightUnavailable));
}
other => panic!("应是失败面板,实际 {other:?}"),
}
let text = on_screen(&page);
assert!(!has_cjk(&text), "{text}");
assert!(text.contains("unreachable"), "失败码要画出来:{text}");
}
+20 -10
View File
@@ -16,7 +16,7 @@
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::AccountNoticeCode;
use super::login::{failure_body_key, failure_title_key};
use super::login::{browser_note_text, failure_body_key, failure_title_key};
use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
@@ -60,23 +60,33 @@ pub(super) fn on_screen(page: &Page) -> String {
DevicePanel::Code {
code,
link,
remaining,
expired,
countdown,
note,
..
} => {
out.push_str(code);
out.push_str(link);
if *expired {
out.push_str(catalog.text(Key::AccountDeviceExpired));
} else if let Some(secs) = remaining {
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]));
// 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
out.push_str(countdown);
// M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`),
// 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。
if let Some(note) = note {
out.push_str(browser_note_text(*note, catalog));
}
}
DevicePanel::Browser { link, .. } => out.push_str(link),
DevicePanel::Failed { failure } => {
DevicePanel::Browser {
link, countdown, ..
} => {
out.push_str(link);
out.push_str(countdown);
}
DevicePanel::Failed { failure, note } => {
out.push_str(catalog.text(failure_title_key(*failure)));
out.push_str(catalog.text(failure_body_key(*failure)));
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
if let Some(note) = note {
out.push_str(browser_note_text(*note, catalog));
}
}
DevicePanel::Succeeded { name } => {
out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)]));
@@ -26,6 +26,8 @@
//! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清
//! (偏离登记在 [`super`] 的偏离表 #7)。
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill;
/// 暗化上限(core 的 `BG_DIM_MAX` = 0.95,经 `unlucky_presenter::prefs` 重导出;这里只给 f32 形态)。
@@ -63,7 +65,7 @@ pub const TILE_SCALE_STEP: f32 = 1.15;
/// 会退化成「留缝」,正是 r1 的 P2-1。真实图片不会走到这个上限。
pub const MAX_TILE_SCALE: f32 = 1024.0;
/// 解码失败的原因(人话在 [`LoadError::describe`])。
/// 解码失败的原因(人话在 [`LoadError::note`])。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError {
/// 读文件失败:不存在 / 没权限 / 被占用。
@@ -79,17 +81,76 @@ pub enum LoadError {
},
}
impl LoadError {
/// 可读诊断(设置页提示行与日志共用同一句)。
/// 一条**可翻译**诊断:`key` + 已经格式化好的参数(模板里只有 `{name}`,见 [`Key`])。
///
/// 为什么不是 `String`:文案表在金表里(M180 `I18NAPPEAR`),本模块只给「哪条 key + 哪些值」。
/// 数字在这一层就格式化好(`{:.2}` / `{:?}` 这些格式说明词表不支持)。
///
/// 为什么 `types` 是 `String` 而不是 `&'static str`:`Io(reason)` / `Undecodable(reason)` 的原因来自
/// 磁盘与解码库(运行时字符串),只能持有。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
key: Key,
args: Vec<(&'static str, String)>,
}
impl Note {
/// 组装一条(参数名必须与译文里的 `{name}` 逐字相同)。
fn new(key: Key, args: Vec<(&'static str, String)>) -> Self {
Self { key, args }
}
/// 这条诊断是哪条文案。
#[must_use]
pub fn describe(&self) -> String {
pub fn key(&self) -> Key {
self.key
}
/// 参数(名 → 已成形的值)。
#[must_use]
pub fn args(&self) -> Vec<(&str, &str)> {
self.args
.iter()
.map(|(name, value)| (*name, value.as_str()))
.collect()
}
/// 按语言取整句(`{name}` 全替换;缺参时占位符原样留着,见 [`unlucky_i18n::catalog::Catalog::render`])。
#[must_use]
pub fn render(&self, lang: Lang) -> String {
lang.catalog().render(self.key, &self.args())
}
/// 英文基线里有没有这个子串。
///
/// 给**跨 mission 的既有断言**用(`app/tests/appearance.rs` 查「诊断里有没有说出原因」),
/// 那条断言不在本 mission 的 scope 里、不能改成按 key 断言。UI 一律走 [`Self::render`]。
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
self.render(Lang::En).contains(needle)
}
}
impl LoadError {
/// 可翻译诊断(设置页提示行与日志共用同一条 key)。
#[must_use]
pub fn note(&self) -> Note {
match self {
Self::Io(reason) => format!("Background image could not be read: {reason}"),
Self::Undecodable(reason) => format!(
"Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)"
Self::Io(reason) => Note::new(
Key::SettingsAppearanceBgReadFailed,
vec![("reason", reason.clone())],
),
Self::TooLarge { width, height } => format!(
"Background image is too large to decode: {width}×{height} (limit {MAX_SOURCE_EDGE} px per edge)"
Self::Undecodable(reason) => Note::new(
Key::SettingsAppearanceBgDecodeFailed,
vec![("reason", reason.clone())],
),
Self::TooLarge { width, height } => Note::new(
Key::SettingsAppearanceBgTooLarge,
vec![
("width", width.to_string()),
("height", height.to_string()),
("limit", MAX_SOURCE_EDGE.to_string()),
],
),
}
}
@@ -193,7 +254,7 @@ fn header_size(bytes: &[u8]) -> Result<(u32, u32), LoadError> {
/// 判据只看图片本身(短边 < [`SMALL_TILE_EDGE`])——视口尺寸在渲染层才知道,而这里要的是
/// 「这张图小到要放大」这个与视口无关的事实。`scale` 条数上限由 [`tiles`] 保证覆盖 ≥ 视口。
#[must_use]
pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<String> {
pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<Note> {
if !matches!(fill, BgFill::Tile) {
return None;
}
@@ -201,9 +262,13 @@ pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<String> {
if width.min(height) >= SMALL_TILE_EDGE {
return None;
}
Some(format!(
"Small image ({}×{}): tiles are scaled up so the window stays covered (max {MAX_TILES} tiles, no gaps).",
image.source.0, image.source.1
Some(Note::new(
Key::SettingsAppearanceBgSmallTile,
vec![
("width", image.source.0.to_string()),
("height", image.source.1.to_string()),
("max", MAX_TILES.to_string()),
],
))
}
@@ -4,6 +4,8 @@
use super::{dim_alpha, load, small_tile_note, tiles, LoadError, BG_DIM_MAX, MAX_TILES};
use std::path::{Path, PathBuf};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill;
fn temp_file(label: &str, name: &str) -> PathBuf {
@@ -66,19 +68,28 @@ fn a_real_jpeg_decodes_too() {
#[test]
fn a_missing_or_broken_file_reports_a_readable_reason() {
// 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。
// 都必须是 Err(不许静默回默认色),且 describe() 是人话。
// 都必须是 Err(不许静默回默认色),且 note() 给出**哪条 key + 哪些参数**(句子在词表里)。
// 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。
let missing = temp_file("missing", "bg.png");
let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错");
assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}");
assert!(error
.describe()
.starts_with("Background image could not be read"));
let note = error.note();
assert_eq!(note.key(), Key::SettingsAppearanceBgReadFailed);
assert!(
note.contains("could not be read"),
"英文基线要说出「读不出来」:{}",
note.render(Lang::En)
);
let broken = temp_file("broken", "bg.png");
std::fs::write(&broken, b"this is not an image").expect("写坏文件");
let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错");
assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}");
assert_eq!(
error.note().key(),
Key::SettingsAppearanceBgDecodeFailed,
"解不开与读不出来必须是两条 key(措辞与修法都不同)"
);
cleanup(&broken);
let empty = load(" ").expect_err("空路径必须报错");
@@ -168,10 +179,21 @@ fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
),
"实际 {error:?}"
);
let note = error.note();
assert_eq!(note.key(), Key::SettingsAppearanceBgTooLarge);
assert!(
error.describe().contains("too large"),
"提示要人话:{}",
error.describe()
note.contains("too large"),
"英文基线要说出「太大」:{}",
note.render(Lang::En)
);
let args = note.args();
assert!(
args.contains(&("width", "9000")) && args.contains(&("height", "4")),
"宽高要作为参数传进去(不是写进译文):{args:?}"
);
assert!(
args.contains(&("limit", "8192")),
"上限走占位符而不是写进译文:{args:?}"
);
// 上限不许再放宽(r1 的 16384 等于允许 1 GB 的中间缓冲):常量级约束放在测试里钉。
const _: () = assert!(super::MAX_SOURCE_EDGE <= 8_192);
@@ -187,8 +209,21 @@ fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
fn small_images_get_a_tile_diagnostic_instead_of_silently_degrading() {
let small = super::Image::from_rgba((32, 32), 4, 4, vec![0; 64]);
let note = small_tile_note(&small, BgFill::Tile).expect("小图 tile 必须有提示");
assert!(note.contains("32×32"), "{note}");
assert!(note.contains("scaled up"), "{note}");
assert_eq!(note.key(), Key::SettingsAppearanceBgSmallTile);
let args = note.args();
assert!(
args.contains(&("width", "32")) && args.contains(&("height", "32")),
"尺寸作为参数:{args:?}"
);
assert!(
args.contains(&("max", "64")),
"块数上限走占位符(`MAX_TILES` 改了不该动译文):{args:?}"
);
assert!(
note.render(Lang::En).contains("scaled up"),
"英文基线要说出「会被放大」:{}",
note.render(Lang::En)
);
// 非 tile 档不提示(cover/contain 会等比铺满,不存在「小图被放大铺」这件事)。
assert!(small_tile_note(&small, BgFill::Cover).is_none());
+6 -55
View File
@@ -20,7 +20,7 @@
//! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) |
//! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) |
//! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` |
//! | `section` | 设置页「外观」分组(真控件) | 无 |
//! | `section/` | 设置页「外观」分组(真控件):壳 + 五段控件,M179 从单文件拆开 | 无 |
//! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 |
//!
//! ## 偏离登记(D30 要求逐条写清)
@@ -65,10 +65,14 @@ pub mod background;
mod palette;
mod picture;
#[cfg(test)]
pub(crate) mod probe;
mod section;
mod state;
#[cfg(test)]
mod tests;
/// 状态提示的 **tonal notice**(`section.rs` 的 400 行硬顶把它放在模块根)。
/// 状态提示的 **tonal notice**(行数硬顶把 `section/` 撑开之后它仍放在模块根)。
///
/// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。
/// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开
@@ -164,56 +168,3 @@ impl Change {
})
}
}
#[cfg(test)]
mod tests {
use super::{Change, APPEARANCE_GROUP};
use unlucky_presenter::prefs::{keys, BgFill, PaletteSource};
/// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。
/// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。
#[test]
fn every_change_names_its_own_schema_key() {
assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance");
let cases = [
(Change::WindowOpacity(0.8), keys::WINDOW_OPACITY),
(Change::PanelAlpha(0.6), keys::PANEL_ALPHA),
(Change::BgEnabled(true), keys::BG_IMAGE_ENABLED),
(Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE),
(Change::BgFill(BgFill::Tile), keys::BG_FILL),
(Change::BgDim(0.5), keys::BG_DIM),
(
Change::PaletteSource(PaletteSource::Wallpaper),
keys::PALETTE_SOURCE,
),
(
Change::Corner(unlucky_presenter::prefs::WindowCorner::Square),
keys::WINDOW_CORNER,
),
];
for (change, key) in cases {
assert_eq!(change.key(), Some(key), "{change:?}");
assert!(change.value().is_some());
}
assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘");
assert_eq!(Change::SelectGroup(2).value(), None);
}
/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。
/// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。
#[test]
fn persisted_values_use_the_schema_vocabulary() {
assert_eq!(
Change::BgFill(BgFill::Contain).value().as_deref(),
Some("contain")
);
assert_eq!(
Change::PaletteSource(PaletteSource::BackgroundImage)
.value()
.as_deref(),
Some("background-image")
);
assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false"));
assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8"));
}
}
+83 -32
View File
@@ -9,7 +9,8 @@
//!
//! 1. 把「没设」与「设了但坏了」**分开传**([`Source::Absent`] vs [`Source::Undecodable`])——
//! 混成一个 `None` 会让设置页说错话(「没设背景图」 vs 「你那张图读不出来」);
//! 2. 把 `Provenance::hint_key()` 翻成一句可读提示(键 → 文案是 UI 层的活,i18n 之后再统一)。
//! 2. 把 `Provenance::hint_key()` 翻成一条词表 key(M180:句子在 `catalog/settings/appearance.rs`,
//! 这里只给 key——中文界面下这句提示也是中文)。
//!
//! ## 为什么不在这里做对比度
//!
@@ -19,6 +20,7 @@
use unlucky_design::color::{Scheme, SchemeFlavor};
use unlucky_design::palette::{resolve_source, ImageInput, Level, Provenance};
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::PaletteSource;
use super::background::Image;
@@ -62,9 +64,9 @@ impl Resolved {
self.provenance.level
}
/// 可读提示(`None` = 一级成功、没什么可解释的)。
/// 提示 key(`None` = 一级成功、没什么可解释的)。
#[must_use]
pub fn note(&self) -> Option<&'static str> {
pub fn note(&self) -> Option<Key> {
note(&self.provenance)
}
}
@@ -97,47 +99,55 @@ pub fn resolve(
Resolved { scheme, provenance }
}
/// `Provenance::hint_key()` → 一句可读提示(英文基线;i18n mission 之后走词表)。
/// `Provenance::hint_key()` → 一条词表 key(M180:句子在 `catalog/settings/appearance.rs`)。
///
/// 键的完整表在 `unlucky_design::palette::resolve::Provenance::hint_key` 的文档里;
/// 这里只翻「本 mission 真会产生的那几个」,未识别键返回 `None` 而不是编一句话。
#[must_use]
pub fn note(provenance: &Provenance) -> Option<&'static str> {
pub fn note(provenance: &Provenance) -> Option<Key> {
Some(match provenance.hint_key()? {
"palette.hint.background.undecodable" => {
"Background image could not be read; colours fall back."
}
"palette.hint.background.too-small" => {
"Background image is too small to sample; colours fall back."
}
"palette.hint.background.bad-dimensions" => {
"Background image data looks inconsistent; colours fall back."
}
"palette.hint.background.undecodable" => Key::SettingsAppearancePaletteBgUndecodable,
"palette.hint.background.too-small" => Key::SettingsAppearancePaletteBgTooSmall,
"palette.hint.background.bad-dimensions" => Key::SettingsAppearancePaletteBgBadDimensions,
"palette.hint.background.no-opaque-pixels" => {
"Background image is almost fully transparent; colours fall back."
Key::SettingsAppearancePaletteBgNoOpaquePixels
}
"palette.hint.background.low-chroma" => {
"Background image is too monochrome to tint from; colours fall back."
"palette.hint.background.low-chroma" => Key::SettingsAppearancePaletteBgLowChroma,
"palette.hint.background.contrast" => Key::SettingsAppearancePaletteBgContrast,
"palette.hint.wallpaper.undecodable" => Key::SettingsAppearancePaletteWallpaperUndecodable,
"palette.hint.wallpaper.low-chroma" => Key::SettingsAppearancePaletteWallpaperLowChroma,
"palette.hint.wallpaper.too-small" => Key::SettingsAppearancePaletteWallpaperTooSmall,
"palette.hint.wallpaper.bad-dimensions" => {
Key::SettingsAppearancePaletteWallpaperBadDimensions
}
"palette.hint.background.contrast" => {
"Background image colours would be unreadable; colours fall back."
}
"palette.hint.wallpaper.undecodable" => "Desktop wallpaper could not be read.",
"palette.hint.wallpaper.low-chroma" => "Desktop wallpaper is too monochrome to tint from.",
"palette.hint.wallpaper.too-small" => "Desktop wallpaper is too small to sample.",
"palette.hint.wallpaper.bad-dimensions" => "Desktop wallpaper data looks inconsistent.",
"palette.hint.wallpaper.no-opaque-pixels" => {
"Desktop wallpaper is almost fully transparent."
Key::SettingsAppearancePaletteWallpaperNoOpaquePixels
}
"palette.hint.wallpaper.contrast" => {
"Desktop wallpaper colours would be unreadable; colours fall back."
}
"palette.hint.preset.contrast" => "The built-in card needed a small contrast correction.",
"palette.hint.adjusted" => "A few colours were nudged for readability.",
"palette.hint.wallpaper.contrast" => Key::SettingsAppearancePaletteWallpaperContrast,
"palette.hint.preset.contrast" => Key::SettingsAppearancePalettePresetContrast,
"palette.hint.adjusted" => Key::SettingsAppearancePaletteAdjusted,
_ => return None,
})
}
/// 生效层级**码值** → 已有的那三条档位 key(M180 复审 P2-2:中文界面里不许留机器码)。
///
/// 码值的全部来源只有两处,取值集合相同:`Level::code()`(`background-image` / `wallpaper` /
/// `preset`)与 `PaletteSource::as_str()`(同三个词)。所以三条 key 就够,**不新增平行词表**——
/// 卡片底部「配色来源」用它们表示**用户选的**档,这一行用它们表示**回落之后真正生效**的档。
///
/// 未识别的码值落到 `background-image` 那条(宁可显示一个偏保守的档名,也不许把机器码漏进
/// 中文界面——这条兜底本身由 `tests` 里的 `every_level_code_maps_to_its_own_sentence` 钉住)。
/// `state.rs` 已贴 400 行线,所以这个映射住在本文件。
#[must_use]
pub fn level_key(code: &str) -> Key {
match code {
"preset" => Key::SettingsAppearancePalettePreset,
"wallpaper" => Key::SettingsAppearancePaletteWallpaper,
_ => Key::SettingsAppearancePaletteBackground,
}
}
#[cfg(test)]
mod tests {
use super::{note, resolve, Source};
@@ -145,6 +155,7 @@ mod tests {
use unlucky_design::color::SchemeFlavor;
use unlucky_design::palette::wcag::check;
use unlucky_design::palette::{Level, Outcome, Skip};
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::PaletteSource;
/// 高彩度的图(纯红):取色一定成功。
@@ -193,7 +204,7 @@ mod tests {
assert!(resolved.provenance.did_fall_back());
assert_eq!(
resolved.note(),
Some("Background image could not be read; colours fall back.")
Some(Key::SettingsAppearancePaletteBgUndecodable)
);
assert!(check(&resolved.scheme).is_clean());
}
@@ -215,7 +226,11 @@ mod tests {
}
other => panic!("灰图必须被判不合格,实际 {other:?}"),
}
assert!(resolved.note().unwrap().contains("monochrome"));
assert_eq!(
resolved.note(),
Some(Key::SettingsAppearancePaletteBgLowChroma),
"灰图的回落原因要指名「彩度不足」"
);
}
#[test]
@@ -276,4 +291,40 @@ mod tests {
None
);
}
/// **M180 复审 P2-2 的守门**:生效层级的**每一个码值**都必须映射到它自己那条中文句子。
///
/// 抓得住:① 三个码值被映射到同一条 key(界面上分不出到底生效的是哪一级);
/// ② 某条映射被删掉 / 写错(`preset` 落到 background 那条 ⇒ 中文界面说「取自背景图」);
/// ③ 有人再加一级却忘了扩映射。
/// 抓不住:译文本身的措辞(那是 `catalog/settings/appearance.rs` 的译文守卫的事)。
///
/// 变异证明:把 `"preset"` 那行删掉(落进兜底)⇒ 本条红,且 `assert_ne!` 直接点名码值。
///
/// 为什么这条必须有:`the_appearance_card_speaks_chinese` 断的是**整句**非 ASCII,
/// 而「配色:preset」里有汉字 ⇒ 整句非 ASCII ⇒ 那个守卫放过它。这个口子只能在这里堵。
#[test]
fn every_level_code_maps_to_its_own_sentence() {
use super::level_key;
let codes = ["background-image", "wallpaper", "preset"];
let keys: Vec<Key> = codes.iter().copied().map(level_key).collect();
assert_eq!(
keys,
vec![
Key::SettingsAppearancePaletteBackground,
Key::SettingsAppearancePaletteWallpaper,
Key::SettingsAppearancePalettePreset,
],
"码值与档位 key 必须一一对应"
);
// 三条中文句子互不相同(否则「映射对了」但界面上仍分不出层级)。
let zh = unlucky_i18n::Lang::ZhCn.catalog();
let mut texts: Vec<&str> = keys.iter().map(|key| zh.text(*key)).collect();
let total = texts.len();
texts.sort_unstable();
texts.dedup();
assert_eq!(texts.len(), total, "三条档位译文有重复:{texts:?}");
}
}
@@ -0,0 +1,68 @@
//! **测试专用**:记录「画一帧时从词表取出的每一条译文」。
//!
//! ## 为什么需要这一层
//!
//! 判据是「中文界面下外观分组渲染出来的字里必须含汉字」,但 iced 0.14 **没有**可以遍历控件树的
//! 公开接口:
//!
//! - `Element::as_widget()` 拿到的是 `&dyn Widget`,而 `Widget::children()` 返回的是**状态** `Tree`
//! (不是子控件),没有 `as_any` 之类的向下转型口;
//! - 想走 `Widget::operate` 得自己造 `Tree` + 跑布局 + 传 `Renderer`,而单测里**没有渲染器**
//! (`pages/download/tests/options_screen.rs` 记的正是这条边界)。
//!
//! 所以取证点退到「字进树之前」那一层:`section::t` / `section::tr` 是本分组**唯一**两个取词口,
//! 它们把每条译文顺手记一份(M180 起分成两份:`t()` 的 `&'static str` 与 `tr()` 替换完占位符的
//! 整句),测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把
//! [`crate::pages::appearance::section`] 跑一遍(并且走 `settings::view` 的语言分发),只是不画像素。
//!
//! ## 为什么是 thread-local
//!
//! `cargo test` 默认多线程跑,每个测试一个线程;thread-local 让并发测试的取证互不串台,
//! 也不需要加锁或串行化。
//!
//! 整个模块只在 `#[cfg(test)]` 下编译,产品路径零代价。
use std::cell::RefCell;
use unlucky_i18n::catalog::Key;
// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序(`t()` 取的 `&'static str`)。
thread_local! {
// clippy 1.99.1(= 本仓 CI 的 stable)对这条 lint **误报**:初始化器已经写成 `const { … }`
// 它还是报「can be made const」。最小复现(clippy-driver 原地可验,无本仓任何符号):
// `thread_local!{ static X: RefCell<Vec<u32>> = const { RefCell::new(Vec::new()) }; }`
// —— 同一个文件加不加 `const` 报同一句。等 clippy 修掉这条误报即可删掉本行 allow。
#[allow(clippy::missing_const_for_thread_local)]
static TEXTS: RefCell<Vec<(Key, &'static str)>> = const { RefCell::new(Vec::new()) };
}
// 自上次 `take_rendered` 以来记录下的 `(key, 整句)`:`tr()` 那条带占位符的路径(M180 加)。
//
// 为什么单独一份而不是把上面那份改成 `String`:M179 的守卫
// (`pages/settings/tests/appearance_section.rs::the_appearance_group_speaks_chinese_when_the_interface_does`)
// 断言的是 `&'static str`,那个文件不在 M180 的 scope 里、不能跟着改签名。两份记录各自的
// `take*` 也各自清空,互不干扰。
thread_local! {
#[allow(clippy::missing_const_for_thread_local)]
static RENDERED: RefCell<Vec<(Key, String)>> = const { RefCell::new(Vec::new()) };
}
/// 记一条(由 [`super::section::t`] 调用;不对外)。
pub(crate) fn record(key: Key, text: &'static str) {
TEXTS.with(|texts| texts.borrow_mut().push((key, text)));
}
/// 记一条**替换完占位符**的整句(由 [`super::section::tr`] 调用;不对外)。
pub(crate) fn record_rendered(key: Key, text: String) {
RENDERED.with(|texts| texts.borrow_mut().push((key, text)));
}
/// 取走并清空本线程记录下的取词(测试用)。
pub(crate) fn take() -> Vec<(Key, &'static str)> {
TEXTS.with(|texts| std::mem::take(&mut *texts.borrow_mut()))
}
/// 取走并清空本线程记录下的整句(测试用)。
pub(crate) fn take_rendered() -> Vec<(Key, String)> {
RENDERED.with(|texts| std::mem::take(&mut *texts.borrow_mut()))
}
@@ -1,395 +0,0 @@
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
//!
//! ## 控件与键的对应
//!
//! | 控件 | 键 | 改动消息 |
//! |---|---|---|
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] |
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
//!
//! 文案一律英文基线(i18n 是别的 mission;本文件不拼中文、不 import 尚不存在的 crate)。
//!
//! ## 为什么「值」与「生效值」分开显示
//!
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
use iced::widget::{button, column, container, row, space};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::{BgFill, PaletteSource, WindowCorner};
use crate::app::components::inputs::{slider, text_field, Field, Slider};
use crate::pages::{frame, Message};
use crate::theme::Ui;
use super::alpha::{
panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction,
WINDOW_ALPHA_FLOOR,
};
use super::background::{self, BG_DIM_MAX};
use super::state::State;
use super::Change;
/// 「外观」分组整块(一张卡)。
pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
// ① 整窗透明度
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
rows = rows.push(slider_row(
ui,
"Window transparency",
window_percent,
format!(
"Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.",
WINDOW_ALPHA_FLOOR
),
state.window_opacity < 1.0,
slider(
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
}),
),
));
// ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。
let corner = state.window_corner();
rows = rows.push(choice_row(
ui,
t(Key::SettingsAppearanceCorner),
vec![
choice(
ui,
t(Key::SettingsAppearanceCornerSystem),
corner == WindowCorner::System,
Change::Corner(WindowCorner::System),
),
choice(
ui,
t(Key::SettingsAppearanceCornerSquare),
corner == WindowCorner::Square,
Change::Corner(WindowCorner::Square),
),
],
));
// **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。
// 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。
if corner == WindowCorner::System && state.window_opacity < 1.0 {
rows = rows.push(super::notice(ui, t(Key::SettingsAppearanceCornerSuspended)));
let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into();
rows = rows.push(gap);
}
let tone = ui.color(Role::OnSurfaceVariant);
rows = rows.push(note_line(ui, t(Key::SettingsAppearanceCornerHint), tone));
// ③ 面板透明度(CPU 档强制 1.0)
let forced = state.panel_is_forced();
let panel_percent = if forced {
format!("100% (forced from {:.2})", state.panel_alpha)
} else {
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
};
let panel_hint = if forced {
"Software renderer: panels stay fully opaque (window transparency still works)."
} else {
"Only the panel/card layer. Floor 0.40; independent of window transparency."
};
let panel_control = if forced {
slider(Slider::new(ui, panel_fraction(1.0)))
} else {
slider(
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
}),
)
};
rows = rows.push(slider_row(
ui,
"Panel transparency",
panel_percent,
panel_hint.to_owned(),
!forced,
panel_control,
));
// ④ 背景图开关
rows = rows.push(choice_row(
ui,
"Background image",
vec![
choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)),
choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)),
],
));
// ⑤ 路径(文本框:受控,输入即生效)
let path_field: Element<'a, Message> = text_field(
ui,
Field::new(&state.bg_path)
.label("Image path")
.placeholder("D:/Pictures/background.png"),
|value| Message::Appearance(Change::BgImage(value)),
);
rows = rows.push(
column![label(ui, "Image path"), path_field]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
);
// ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
for note in state.diagnostics() {
rows = rows.push(note_line(ui, note, ui.color(Role::Error)));
}
if let Some(note) = state.backend_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state
.image()
.and_then(|image| background::small_tile_note(image, state.bg_fill))
{
rows = rows.push(note_line(ui, &note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state.palette_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
let resolved = format!("Colours: {}", state.palette_level());
rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant)));
// ⑦ 填充
rows = rows.push(choice_row(
ui,
"Fill",
vec![
choice(
ui,
"Cover",
state.bg_fill == BgFill::Cover,
Change::BgFill(BgFill::Cover),
),
choice(
ui,
"Contain",
state.bg_fill == BgFill::Contain,
Change::BgFill(BgFill::Contain),
),
choice(
ui,
"Tile",
state.bg_fill == BgFill::Tile,
Change::BgFill(BgFill::Tile),
),
],
));
// ⑧ 暗化
rows = rows.push(slider_row(
ui,
"Dim",
format!("{:.0}%", state.bg_dim * 100.0),
format!(
"Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}."
),
state.bg_dim > 0.0,
slider(
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
),
));
// ⑨ 配色来源
rows = rows.push(choice_row(
ui,
"Colour source",
vec![
choice(
ui,
"Background",
state.palette_source == PaletteSource::BackgroundImage,
Change::PaletteSource(PaletteSource::BackgroundImage),
),
choice(
ui,
"Wallpaper",
state.palette_source == PaletteSource::Wallpaper,
Change::PaletteSource(PaletteSource::Wallpaper),
),
choice(
ui,
"Preset",
state.palette_source == PaletteSource::Preset,
Change::PaletteSource(PaletteSource::Preset),
),
],
));
card(ui, rows)
}
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let spec = Component::OutlinedCard.spec();
let background = ui.layer(MaterialLayer::Card);
container(content)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: ui.outlined(spec.shape),
..container::Style::default()
})
.into()
}
// --------------------------------------------------------------- 行与控件
fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
content,
ui.color(Role::OnSurface),
)
}
/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。
///
/// 为什么是 [`Lang::En`]:本文件的既有文案是英文基线(i18n 接线是另一条 mission,
/// 见本文件头部「文案一律英文基线」)。走词表的意义是**新增条目已经有中英两套**,
/// 接线时只换这一行,不用回来重写控件。
fn t(key: Key) -> &'static str {
unlucky_i18n::Lang::En.catalog().text(key)
}
fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
}
/// 一行「标签 + 值 + 滑块 + 说明」。
fn slider_row<'a>(
ui: &'a Ui,
title: &'a str,
value: String,
hint: String,
enabled: bool,
control: Element<'a, Message>,
) -> Element<'a, Message> {
let value_color = if enabled {
ui.color(Role::OnSurface)
} else {
ui.color(Role::OnSurfaceVariant)
};
let head = row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
]
.width(Length::Fill)
.align_y(Alignment::Center);
column![
head,
control,
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一行「标签 + 一排互斥按钮」。
fn choice_row<'a>(
ui: &'a Ui,
title: &'a str,
choices: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
row(choices)
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
fn choice<'a>(ui: &'a Ui, text: &'a str, selected: bool, change: Change) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
button(
container(frame::text_role(
ui,
TypeRole::LabelLarge,
selected,
text,
content_color,
))
.align_y(Alignment::Center),
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(move |_theme: &IcedTheme, status| {
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
Some(Background::Color(ui.state_over(
unlucky_design::color::StateLayer::Hover,
Role::OnSurface,
Role::Surface,
)))
} else {
None
};
button::Style {
background,
text_color: content_color,
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Appearance(change))
.into()
}
@@ -0,0 +1,144 @@
//! 外观分组第 ④ ⑤ ⑥ ⑦ ⑧ 段:**背景图**——开关、路径、诊断、填充档、暗化强度。
//!
//! 这五段挤在一起是因为它们读同一份 `bg_*` 状态,而且**诊断必须紧贴控件**(复审 r1 P2-3:
//! 读盘失败 / CPU 降级 / 配色回落这些提示落在折叠线以下等于没提示)。
use iced::widget::column;
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill;
use crate::app::components::inputs::{slider, text_field, Field, Slider};
use crate::pages::Message;
use crate::theme::Ui;
use super::super::background::{self, BG_DIM_MAX};
use super::super::palette;
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row, fixed2, label, note_line, slider_row, t, tr, tr_note};
/// 把 ④ 开关、⑤ 路径、⑥ 诊断、⑦ 填充、⑧ 暗化按视觉顺序推进 `rows`。
pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
rows: &mut Vec<Element<'a, Message>>,
) {
// ④ 背景图开关
rows.push(choice_row(
ui,
t(Key::SettingsAppearanceBackgroundImage, lang),
vec![
choice(
ui,
t(Key::SettingsAppearanceOff, lang),
!state.bg_enabled,
Change::BgEnabled(false),
),
choice(
ui,
t(Key::SettingsAppearanceOn, lang),
state.bg_enabled,
Change::BgEnabled(true),
),
],
));
// ⑤ 路径(文本框:受控,输入即生效)
let path_field: Element<'a, Message> = text_field(
ui,
Field::new(&state.bg_path)
.label(t(Key::SettingsAppearanceImagePath, lang))
.placeholder("D:/Pictures/background.png"),
|value| Message::Appearance(Change::BgImage(value)),
);
rows.push(
column![
label(ui, t(Key::SettingsAppearanceImagePath, lang)),
path_field
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into(),
);
// ⑥ 诊断(读盘失败 / 配色回落)——**不许静默**,且必须在**首屏可见处**
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
//
// CPU 档的「面板/玻璃被强制」那句**不在这里**:它是张卡级状态,挂在被禁用的面板滑块旁边
// (`panel.rs` 的 `PanelOpacityForcedHint`),一屏只出现一次(M180 复审 P2-1)。
for note in state.diagnostics() {
let text = tr_note(lang, note);
rows.push(note_line(ui, &text, ui.color(Role::Error)));
}
if let Some(note) = state
.image()
.and_then(|image| background::small_tile_note(image, state.bg_fill))
{
let text = tr_note(lang, &note);
rows.push(note_line(ui, &text, ui.color(Role::OnSurfaceVariant)));
}
if let Some(key) = state.palette_note() {
rows.push(note_line(
ui,
t(key, lang),
ui.color(Role::OnSurfaceVariant),
));
}
// 生效的配色层级:显示的是**回落之后真正生效**的那一级(与底部「配色来源」里用户选的那一档
// 不一定相同)。码值 → 已有的三条档位 key(不新增平行词表),所以中文界面也读得懂。
let level = tr(
lang,
Key::SettingsAppearancePaletteLevel,
&[("level", t(palette::level_key(state.palette_level()), lang))],
);
rows.push(note_line(ui, &level, ui.color(Role::OnSurfaceVariant)));
// ⑦ 填充
rows.push(choice_row(
ui,
t(Key::SettingsAppearanceBgFill, lang),
vec![
choice(
ui,
t(Key::SettingsAppearanceBgFillCover, lang),
state.bg_fill == BgFill::Cover,
Change::BgFill(BgFill::Cover),
),
choice(
ui,
t(Key::SettingsAppearanceBgFillContain, lang),
state.bg_fill == BgFill::Contain,
Change::BgFill(BgFill::Contain),
),
choice(
ui,
t(Key::SettingsAppearanceBgFillTile, lang),
state.bg_fill == BgFill::Tile,
Change::BgFill(BgFill::Tile),
),
],
));
// ⑧ 暗化
rows.push(slider_row(
ui,
t(Key::SettingsAppearanceBgDim, lang),
format!("{:.0}%", state.bg_dim * 100.0),
tr(
lang,
Key::SettingsAppearanceBgDimHint,
&[("max", &fixed2(BG_DIM_MAX))],
),
state.bg_dim > 0.0,
slider(
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
),
));
}
@@ -0,0 +1,47 @@
//! 外观分组第 ⑨ 段:**配色来源**(背景图 / 壁纸 / 色卡三档;它选的正是 D38 三级优先序的起点)。
use iced::Element;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::PaletteSource;
use crate::pages::Message;
use crate::theme::Ui;
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row, t};
/// 把 ⑨ 配色来源推进行 `rows`。
pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
rows: &mut Vec<Element<'a, Message>>,
) {
// ⑨ 配色来源
rows.push(choice_row(
ui,
t(Key::SettingsAppearancePaletteSource, lang),
vec![
choice(
ui,
t(Key::SettingsAppearancePaletteBackground, lang),
state.palette_source == PaletteSource::BackgroundImage,
Change::PaletteSource(PaletteSource::BackgroundImage),
),
choice(
ui,
t(Key::SettingsAppearancePaletteWallpaper, lang),
state.palette_source == PaletteSource::Wallpaper,
Change::PaletteSource(PaletteSource::Wallpaper),
),
choice(
ui,
t(Key::SettingsAppearancePalettePreset, lang),
state.palette_source == PaletteSource::Preset,
Change::PaletteSource(PaletteSource::Preset),
),
],
));
}
@@ -0,0 +1,286 @@
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
//!
//! ## 控件与键的对应
//!
//! | 控件 | 键 | 改动消息 |
//! |---|---|---|
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] |
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
//! | 填充(铺满 / 完整显示 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
//!
//! ## 文件分工(M179 `I18NSEAM`:纯搬移 + 语言接线)
//!
//! 本模块原来是一个 395 行的 `section.rs`,而全仓行数守卫是 400(`components/gate.rs`),
//! 剩 5 行——加不了任何东西。M179 按「一段控件一个文件」切开了它:
//!
//! | 文件 | 是什么 |
//! |---|---|
//! | 本文件(`section/mod.rs`) | **壳**:[`section`] 按顺序拼各段 + [`card`] 卡片 + 行控件([`label`] / [`slider_row`] / [`choice_row`] / [`choice`] / [`note_line`])+ 两个取词口 [`t`] / [`tr`] |
//! | [`window`] | ① 整窗透明度 + ② 窗口圆角(含「因整窗 α 不生效」的 notice 与说明段) |
//! | [`panel`] | ③ 面板透明度(CPU 档强制 1.0,值 / 生效值分开显示) |
//! | [`bg_image`] | ④ 背景图开关 + ⑤ 路径 + ⑥ 诊断 + ⑦ 填充 + ⑧ 暗化 |
//! | [`colour_source`] | ⑨ 配色来源(三档) |
//!
//! 拆分口径:**纯搬移**——每段的语句逐字搬过去,只有下面这几处机械改写(M179 报告里用「去空白/
//! 去尾逗号 diff」逐段证明:改完之后与原文件逐字相同,断言一条没动):
//!
//! 1. `rows = rows.push(…)` → `rows.push(…)`(壳现在传 `&mut Vec<Element>`);
//! 2. `t(Key::X)` → `t(Key::X, lang)`(本 mission 的语言接线);
//! 3. `super::notice(…)` → `super::super::notice(…)`(多了一层模块);
//! 4. 唯一一处非 `Element` 实参:`rows.push(column![…])` → 末尾加 `.into()`(`Vec::push` 不做 `Into`);
//! 5. rustfmt 因为多出来的 `, lang` 把两处调用换成了多行。
//!
//! ## 语言(M179 接过管子,M180 补齐文案)
//!
//! [`t`] / [`tr`] 是**唯一**两个取词口(一个拿 `&'static str`、一个拿带占位符的 owned 句子),
//! `lang` 从 `settings::view` 的 `Language.effective` → `Group.body` → [`section`] 一路递进来。
//! M179 只把管子接通(当时本组 27 条文案里真正本地化的只有圆角那 5 条);M180 `I18NAPPEAR` 把
//! 剩下 22 条字面量与 `state.rs` / `background.rs` / `palette.rs` 的运行时诊断全部搬进
//! `catalog/settings/appearance.rs`,于是中文界面上这一页的**卡片文案**没有一句是英文。
//!
//! 两处例外(M180 复审 L-1 记下的口径):① 写进 `{reason}` 的**原因串**里,来自本仓库自写的
//! 两条(`"no path configured"`、`"unknown image format …"`)与 OS / 解码库给出的文本仍是英文——
//! 它们是不可翻译的第三方或诊断原文;② `Windows 11` / `macOS` / `Linux` / `PNG` / `JPEG` / `BMP`
//! 这些专名。除此之外这一页不出现英文。
//!
//! ## 为什么「值」与「生效值」分开显示
//!
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
use iced::widget::{button, column, container, row};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use crate::pages::{frame, Message};
use crate::theme::Ui;
use super::state::State;
use super::Change;
mod bg_image;
mod colour_source;
mod panel;
mod window;
/// 「外观」分组整块(一张卡)。
///
/// `lang` 是**这一次生效的语言**:`settings::view` 的 `Language.effective`(M179)。壳把各段按
/// **视觉顺序**拼起来;每一段的语句逐字来自拆分前的 `section()`。
pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
let mut rows: Vec<Element<'a, Message>> = Vec::new();
window::push(ui, state, lang, &mut rows);
panel::push(ui, state, lang, &mut rows);
bg_image::push(ui, state, lang, &mut rows);
colour_source::push(ui, state, lang, &mut rows);
card(
ui,
column(rows)
.spacing(ui.space(Space::X1))
.width(Length::Fill),
)
}
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let spec = Component::OutlinedCard.spec();
let background = ui.layer(MaterialLayer::Card);
container(content)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: ui.outlined(spec.shape),
..container::Style::default()
})
.into()
}
// --------------------------------------------------------------- 行与控件
//
// 五个行控件由各段共用,所以留在壳里(`pub(super)` = 只给 `section/` 下的兄弟模块用)。
pub(super) fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
content,
ui.color(Role::OnSurface),
)
}
/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。
///
/// `lang` 是**这一次生效的语言**,由 [`section`] 一路递下来(M179 `I18NSEAM` 之前这里按英文目录
/// 取词,于是中文界面上本分组的 27 条文案里真正本地化 0 条)。现在这个函数是全模块
/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M180 `I18NAPPEAR` 已把这一页剩余的 22 处
/// 字面量与全部运行时诊断搬进词表,见 `catalog/settings/appearance.rs` 的模块文档。
pub(super) fn t(key: Key, lang: Lang) -> &'static str {
let text = lang.catalog().text(key);
// 测试专用:把「这一帧真的取了哪条译文」记一份(iced 0.14 的 `Element` 没有可遍历控件树的
// 公开接口,所以「渲染出来的字是不是中文」只能在取词口取证,见 `appearance::probe`)。
#[cfg(test)]
super::probe::record(key, text);
text
}
/// 带占位符的句子(`{name}` 替换后的 owned 版本)。
///
/// 为什么不直接 `lang.catalog().render(...)`:这条路径也要进 [`t`] 那份取证记录
/// (`probe::record_rendered`),否则「中文界面下这一页每一句都不是纯 ASCII」的守卫会漏掉所有
/// 带数字的说明句。数值参数由调用方先格式化好(词表只认 `{name}`,不认 `{:.2}`)。
pub(super) fn tr(lang: Lang, key: Key, args: &[(&str, &str)]) -> String {
let text = lang.catalog().render(key, args);
#[cfg(test)]
super::probe::record_rendered(key, text.clone());
text
}
/// 两位小数(`{:.2}` 这种格式说明由 Rust 侧做,词表里只留 `{name}`)。
pub(super) fn fixed2(value: f32) -> String {
format!("{value:.2}")
}
/// 一条 [`Note`](key + 已格式化的参数)按语言取整句;与 [`tr`] 共用取证口。
pub(super) fn tr_note(lang: Lang, note: &super::background::Note) -> String {
tr(lang, note.key(), &note.args())
}
pub(super) fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
}
/// 一行「标签 + 值 + 滑块 + 说明」。
pub(super) fn slider_row<'a>(
ui: &'a Ui,
title: &'a str,
value: String,
hint: String,
enabled: bool,
control: Element<'a, Message>,
) -> Element<'a, Message> {
let value_color = if enabled {
ui.color(Role::OnSurface)
} else {
ui.color(Role::OnSurfaceVariant)
};
let head = row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
]
.width(Length::Fill)
.align_y(Alignment::Center);
column![
head,
control,
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一行「标签 + 一排互斥按钮」。
pub(super) fn choice_row<'a>(
ui: &'a Ui,
title: &'a str,
choices: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
row(choices)
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
pub(super) fn choice<'a>(
ui: &'a Ui,
text: &'a str,
selected: bool,
change: Change,
) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
button(
container(frame::text_role(
ui,
TypeRole::LabelLarge,
selected,
text,
content_color,
))
.align_y(Alignment::Center),
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(move |_theme: &IcedTheme, status| {
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
Some(Background::Color(ui.state_over(
unlucky_design::color::StateLayer::Hover,
Role::OnSurface,
Role::Surface,
)))
} else {
None
};
button::Style {
background,
text_color: content_color,
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Appearance(change))
.into()
}
@@ -0,0 +1,62 @@
//! 外观分组第 ③ 段:**面板透明度**滑块(下限 0.4;CPU 档强制 1.0 并禁用滑块)。
use iced::Element;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use crate::app::components::inputs::{slider, Slider};
use crate::pages::Message;
use crate::theme::Ui;
use super::super::alpha::{panel_alpha_from_fraction, panel_fraction, PANEL_ALPHA_FLOOR};
use super::super::state::State;
use super::super::Change;
use super::{fixed2, slider_row, t, tr};
/// 把 ③ 面板透明度推进 `rows`。
pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
rows: &mut Vec<Element<'a, Message>>,
) {
// ③ 面板透明度(CPU 档强制 1.0)
let forced = state.panel_is_forced();
let panel_percent = if forced {
// 数值占位:`{requested}` 是请求值(词表不认 `{:.2}`,所以在这里先格式化成两位小数)。
tr(
lang,
Key::SettingsAppearancePanelOpacityForcedValue,
&[("requested", &fixed2(state.panel_alpha))],
)
} else {
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
};
let panel_hint = if forced {
t(Key::SettingsAppearancePanelOpacityForcedHint, lang).to_owned()
} else {
tr(
lang,
Key::SettingsAppearancePanelOpacityHint,
&[("floor", &fixed2(PANEL_ALPHA_FLOOR))],
)
};
let panel_control = if forced {
slider(Slider::new(ui, panel_fraction(1.0)))
} else {
slider(
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
}),
)
};
rows.push(slider_row(
ui,
t(Key::SettingsAppearancePanelOpacity, lang),
panel_percent,
panel_hint,
!forced,
panel_control,
));
}
@@ -0,0 +1,87 @@
//! 外观分组第 ① ② 段:**整窗**两件——整窗透明度滑块、窗口圆角(含 notice 与说明段)。
//!
//! 这两段是唯一**一个控件牵动另一个控件**的地方:圆角「跟随系统」时把整窗透明度调低会让圆角失效
//! (`DwmExtendFrameIntoClientArea` 的世界里没有半透明边框),所以只有这一对放在同一个文件里——
//! notice 的判据(`corner == System && window_opacity < 1.0`)跨这两段。
use iced::widget::space;
use iced::Element;
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::WindowCorner;
use crate::app::components::inputs::{slider, Slider};
use crate::pages::Message;
use crate::theme::Ui;
use super::super::alpha::{window_alpha_from_fraction, window_fraction, WINDOW_ALPHA_FLOOR};
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row, fixed2, note_line, slider_row, t, tr};
/// 把 ① 整窗透明度与 ② 窗口圆角按视觉顺序推进 `rows`。
pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
rows: &mut Vec<Element<'a, Message>>,
) {
// ① 整窗透明度
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
rows.push(slider_row(
ui,
t(Key::SettingsAppearanceWindowOpacity, lang),
window_percent,
tr(
lang,
Key::SettingsAppearanceWindowOpacityHint,
&[("floor", &fixed2(WINDOW_ALPHA_FLOOR))],
),
state.window_opacity < 1.0,
slider(
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
}),
),
));
// ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。
let corner = state.window_corner();
rows.push(choice_row(
ui,
t(Key::SettingsAppearanceCorner, lang),
vec![
choice(
ui,
t(Key::SettingsAppearanceCornerSystem, lang),
corner == WindowCorner::System,
Change::Corner(WindowCorner::System),
),
choice(
ui,
t(Key::SettingsAppearanceCornerSquare, lang),
corner == WindowCorner::Square,
Change::Corner(WindowCorner::Square),
),
],
));
// **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。
// 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。
if corner == WindowCorner::System && state.window_opacity < 1.0 {
rows.push(super::super::notice(
ui,
t(Key::SettingsAppearanceCornerSuspended, lang),
));
let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into();
rows.push(gap);
}
let tone = ui.color(Role::OnSurfaceVariant);
rows.push(note_line(
ui,
t(Key::SettingsAppearanceCornerHint, lang),
tone,
));
}
+9 -17
View File
@@ -17,6 +17,7 @@
//! 变成一条**可读诊断**而不是一个 `unwrap`。
use unlucky_design::color::{Scheme, SchemeFlavor};
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::{
AppearanceBase, AppearancePrefs, BgFill, PaletteSource, WindowCorner,
};
@@ -24,7 +25,7 @@ use unlucky_presenter::prefs::{
use crate::Backend;
use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque};
use super::background::{self, Image};
use super::background::{self, Image, Note};
use super::palette::{self, Resolved, Source};
/// 取证开关的环境变量名(截图矩阵与真机日志用;生产默认全部不设)。
@@ -67,8 +68,8 @@ pub struct State {
pub selected_group: usize,
/// 已解码的背景图(`None` = 没设 / 读失败)。
image: Option<Image>,
/// 读盘/解码失败的**可读诊断**(按发生顺序追加)。
diagnostics: Vec<String>,
/// 读盘/解码失败的**可读诊断**(按发生顺序追加;`Note` = key + 参数,句子在词表里)。
diagnostics: Vec<Note>,
/// 上一次的配色解析结果。
resolved: Option<Resolved>,
/// 当前深浅(配色解析的输入)。
@@ -196,24 +197,15 @@ impl State {
Some(&resolved.scheme)
}
/// 读盘/解码失败的诊断(设置页逐条显示)。
/// 读盘/解码失败的诊断(设置页逐条显示;一条 = 一条 key + 它的参数)。
#[must_use]
pub fn diagnostics(&self) -> &[String] {
pub fn diagnostics(&self) -> &[Note] {
&self.diagnostics
}
/// CPU 档降级的说明(`None` = 没有降级)。
/// 配色来源的提示 key(`None` = 一级成功)。
#[must_use]
pub fn backend_note(&self) -> Option<&'static str> {
self.panel_is_forced().then_some(
"Software renderer: panel transparency and glass stay fully opaque (window \
transparency still works).",
)
}
/// 配色来源的提示(`None` = 一级成功)。
#[must_use]
pub fn palette_note(&self) -> Option<&'static str> {
pub fn palette_note(&self) -> Option<Key> {
self.resolved.as_ref()?.note()
}
@@ -362,7 +354,7 @@ impl State {
}
match background::load(trimmed) {
Ok(image) => self.image = Some(image),
Err(error) => self.diagnostics.push(error.describe()),
Err(error) => self.diagnostics.push(error.note()),
}
}
@@ -0,0 +1,263 @@
//! `pages::appearance` 的单测:`Change` 的落盘映射(M93)+ **中文界面文案的取证守卫**(M180)。
//!
//! 为什么是独立文件:`mod.rs` 已经 220 行上下,而 M180 要在模块里加三条取证用例。搬移口径与
//! `background/tests.rs` 一致——既有两条用例**一字未动**,只是从 `mod.rs` 的行内 `mod tests` 挪到这里。
//!
//! ## M180 的两条取证守卫,它们「抓得住什么」
//!
//! iced 0.14 没有可遍历控件树的公开接口(见 [`super::probe`] 的模块文档),所以取证点只能是
//! 取词口 `section::t` / `section::tr`。于是这一对守卫分工如下:
//!
//! | 守卫 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`the_appearance_card_speaks_chinese`] | 中文界面下取到的每一条文案都是纯 ASCII(= 有人把某条 key 的英文写回中文表 / 中文表里放英文原句) | 硬编码字面量(它压根不过取词口) |
//! | [`every_appearance_card_key_is_drawn`] | 某一处控件被改回**写死英文**:那条 key 不再被取,集合里就少一个元素 | 新加一条字面量(新 key 还没有,集合不看它) |
//!
//! 两条合起来才等于「中文界面下外观卡片里不再出现英文字面量」——单看任一条都有一个真会发生的
//! 漏网口,所以这里刻意写两条。
use super::{Change, APPEARANCE_GROUP};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::{keys, BgFill, PaletteSource};
/// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。
/// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。
#[test]
fn every_change_names_its_own_schema_key() {
assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance");
let cases = [
(Change::WindowOpacity(0.8), keys::WINDOW_OPACITY),
(Change::PanelAlpha(0.6), keys::PANEL_ALPHA),
(Change::BgEnabled(true), keys::BG_IMAGE_ENABLED),
(Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE),
(Change::BgFill(BgFill::Tile), keys::BG_FILL),
(Change::BgDim(0.5), keys::BG_DIM),
(
Change::PaletteSource(PaletteSource::Wallpaper),
keys::PALETTE_SOURCE,
),
(
Change::Corner(unlucky_presenter::prefs::WindowCorner::Square),
keys::WINDOW_CORNER,
),
];
for (change, key) in cases {
assert_eq!(change.key(), Some(key), "{change:?}");
assert!(change.value().is_some());
}
assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘");
assert_eq!(Change::SelectGroup(2).value(), None);
}
/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。
/// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。
#[test]
fn persisted_values_use_the_schema_vocabulary() {
assert_eq!(
Change::BgFill(BgFill::Contain).value().as_deref(),
Some("contain")
);
assert_eq!(
Change::PaletteSource(PaletteSource::BackgroundImage)
.value()
.as_deref(),
Some("background-image")
);
assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false"));
assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8"));
}
// --------------------------------------------------------------- 文案(M180 I18NAPPEAR)
/// 画一帧外观卡片,取走这一帧从词表取到的**全部**字:`(key, 文案)`。
///
/// 三档状态各画一帧:
/// - [`Case::Factory`]:auto 档、没设背景图——覆盖常驻的标签 / 选项 / 说明;
/// - [`Case::Cpu`]:CPU 档——覆盖「强制 100%」那一行与软件渲染说明(这两条只在降级态出现);
/// - [`Case::Dim`]:整窗 α 调低——覆盖「圆角暂时不生效」那条 notice。
///
/// 两条取词路径都要收:`t()` 的 `&'static str` 与 `tr()` 替换完占位符的整句(后者不进前者的记录)。
fn appearance_texts(lang: Lang, case: Case) -> Vec<(Key, String)> {
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs};
use super::probe;
use crate::cli::Backend;
use crate::theme::{Families, Ui};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
let (backend, alpha) = match case {
Case::Factory => (Backend::Auto, 1.0),
Case::Cpu => (Backend::Cpu, 1.0),
Case::Dim => (Backend::Gpu, 0.5),
};
let mut state = super::State::new(
&AppearancePrefs::factory(),
&AppearanceBase::factory(),
backend,
SchemeFlavor::Light,
);
if alpha < 1.0 {
state.apply(Change::WindowOpacity(alpha));
}
let ui = Ui::new(SchemeFlavor::Light, FAMILIES);
// 前面几条用例可能留了记录:本线程的取证每次都从清空开始。
probe::take();
probe::take_rendered();
let _tree = super::section(&ui, &state, lang);
let mut texts: Vec<(Key, String)> = probe::take()
.into_iter()
.map(|(key, text)| (key, text.to_owned()))
.collect();
texts.extend(probe::take_rendered());
// 复审 L-2:`{name}` 换完还留着花括号 ⇒ 某处参数名与模板不一致(`("floor", …)` 写成
// `("flor", …)` 时句子会原样显示「下限 {floor}」,而「整句非 ASCII」的守卫看不出来)。
// 三档状态合起来覆盖本页全部 `tr()` 调用,所以这条断言是全覆盖的。
for (key, text) in &texts {
assert!(
!text.contains('{'),
"{case:?}:{} 渲染后还留着占位符「{text}」——`tr()` 的参数名与模板对不上",
key.name()
);
}
texts
}
/// 画哪一档状态。
#[derive(Debug, Clone, Copy)]
enum Case {
/// 工厂态(GPU / auto)。
Factory,
/// CPU 降级态(面板 α 与玻璃被强制)。
Cpu,
/// 整窗 α 调低(圆角暂时不生效)。
Dim,
}
/// 这一页**常驻**的 key(三档状态合起来必须一个不缺地取到)。
///
/// 为什么是手写清单而不是「`Key::ALL` 里所有 `SettingsAppearance*`」:诊断一族(读盘失败 / 配色回落 /
/// 小图平铺)取决于运行时状态,单测里要让它们出现得造磁盘上的坏图;那些由
/// `background/tests.rs` 与 `palette.rs` 的单测按 key 逐条钉(见各自的用例注释)。
const CARD_KEYS: &[Key] = &[
Key::SettingsAppearanceCorner,
Key::SettingsAppearanceCornerSystem,
Key::SettingsAppearanceCornerSquare,
Key::SettingsAppearanceCornerHint,
Key::SettingsAppearanceCornerSuspended,
Key::SettingsAppearanceWindowOpacity,
Key::SettingsAppearanceWindowOpacityHint,
Key::SettingsAppearancePanelOpacity,
Key::SettingsAppearancePanelOpacityHint,
Key::SettingsAppearancePanelOpacityForcedValue,
Key::SettingsAppearancePanelOpacityForcedHint,
Key::SettingsAppearanceBackgroundImage,
Key::SettingsAppearanceOff,
Key::SettingsAppearanceOn,
Key::SettingsAppearanceImagePath,
Key::SettingsAppearancePaletteLevel,
Key::SettingsAppearanceBgFill,
Key::SettingsAppearanceBgFillCover,
Key::SettingsAppearanceBgFillContain,
Key::SettingsAppearanceBgFillTile,
Key::SettingsAppearanceBgDim,
Key::SettingsAppearanceBgDimHint,
Key::SettingsAppearancePaletteSource,
Key::SettingsAppearancePaletteBackground,
Key::SettingsAppearancePaletteWallpaper,
Key::SettingsAppearancePalettePreset,
];
/// **M180 守门 ①**:中文界面下,外观卡片里取到的每一条文案都不是纯 ASCII。
///
/// 抓得住:某条 key 的中文译文被换成英文原句(中文表里出现纯 ASCII 的一句);中文译文写成
/// `PNG` / `Preset` 这种专名;`tr()` 那条路径把数字拼进英文模板(比如 `Colours: preset`)。
/// 抓不住:硬编码字面量——它不过取词口,由 [`every_appearance_card_key_is_drawn`] 与
/// `catalog/settings/appearance.rs` 的译文守卫合力守。
///
/// 变异证明:把 `SettingsAppearanceOff` 的中文改成 `"Off"` ⇒ 本条立刻红。
#[test]
fn the_appearance_card_speaks_chinese() {
// 白名单:技术专名允许是纯 ASCII(现在一条都没有——`%` / 数字不在文案里,它们是参数)。
const ASCII_OK: &[&str] = &[];
for case in [Case::Factory, Case::Cpu, Case::Dim] {
let pairs = appearance_texts(Lang::ZhCn, case);
assert!(
pairs.len() >= 20,
"{case:?}:取词口只记到 {} 条文案,渲染路径没走到底(语言管子断了?)",
pairs.len()
);
for (key, text) in &pairs {
if ASCII_OK.contains(&text.as_str()) {
continue;
}
assert!(
!text.is_ascii(),
"{case:?}:中文界面下外观卡片里出现了纯 ASCII 的文案「{text}」({})\
——写死英文,或中文表里放了英文原句",
key.name()
);
}
}
}
/// **M180 守门 ②(漏项)**:这一页每一处控件都必须从词表取词。
///
/// 抓得住:把某处 `t(Key::X, lang)` / `tr(lang, Key::X, …)` 改回写死的英文字面量——那条 key 不再
/// 出现在取词记录里,集合比较当场红(这正是 M172 口径「22 处硬编码」的复发形态)。
/// 抓不住:新加一条写死英文(它没有 key,也就没有「少了哪条」)。
///
/// 变异证明:把 `bg_image.rs` 里 `t(Key::SettingsAppearanceOff, lang)` 换回 `"Off"` ⇒ 本条红。
#[test]
fn every_appearance_card_key_is_drawn() {
let mut seen: Vec<Key> = Vec::new();
for case in [Case::Factory, Case::Cpu, Case::Dim] {
seen.extend(
appearance_texts(Lang::ZhCn, case)
.into_iter()
.map(|(key, _)| key),
);
}
let missing: Vec<&str> = CARD_KEYS
.iter()
.filter(|key| !seen.contains(key))
.map(|key| key.name())
.collect();
assert!(
missing.is_empty(),
"这些 key 一帧都没取到(控件被改回写死英文,或整段没画):{missing:?}"
);
}
/// 英文界面下不许出现汉字(否则「含汉字」可能来自写死的中文,而不是词表)。
#[test]
fn the_appearance_card_keeps_its_english_baseline() {
for case in [Case::Factory, Case::Cpu, Case::Dim] {
let texts = appearance_texts(Lang::En, case);
let with_han: Vec<&(Key, String)> = texts
.iter()
.filter(|(_, text)| text.chars().any(is_han))
.collect();
assert!(
with_han.is_empty(),
"{case:?}:英文界面下外观卡片出现了汉字:{with_han:?}"
);
}
}
/// 有没有汉字(与 `pages/settings/tests/appearance_section.rs` 的 `is_han` 同一段码位)。
fn is_han(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
}
+14 -1
View File
@@ -47,6 +47,16 @@
//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 |
//! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 |
//! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 |
//!
//! ## 安装失败之后:两档分组 + 「只重试这些」(M191)
//!
//! 装入失败且**有可重试项**时,页底那块失败面板换成 [`parts::retry_panel`]:按
//! `InstallFailureKind` 分两段(不兼容 / 取不到,各有自己的词表文案),并给一个「只重试这 N 个」
//! 的实心按钮。按钮发 [`Message::RetryFailed`],app 层翻成 `AppCommand::InstallContent{retry: true}`,
//! host 拿上一轮的计划调 `DependencyPlan::retry_request()` ⇒ **只请求失败的那几个项目**。
//!
//! 判据只有一条且在模型里(`RetryPanel::of`):`stage == Failed && !retry.is_empty()`。
//! 全成功、没有失败项、还在跑,三种情况都得到 `None`——**不渲染**,不是画一个灰按钮。
mod demo;
mod layout;
@@ -61,7 +71,10 @@ mod view;
mod tests;
pub use message::Message;
pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target};
pub use model::{
loader_name, Blocker, Content, ContentQueryView, Hit, PageState, RetryGroup, RetryPanel,
Status, Target,
};
// M113: import/export
pub use packui::{request_export, Pack, PackMessage, PackPageState};
+80 -2
View File
@@ -11,7 +11,7 @@
//! | 变量 | 取值 | 作用 |
//! |---|---|---|
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) |
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` / `retryfail` / `retryok` | 选状态(默认 `ok`;`offline` = M162 的未联网态;`retryfail` = M191 的「有失败项」,`retryok` = M191 的「全部成功」) |
//!
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
@@ -19,10 +19,52 @@ use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
use super::model::{
blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow,
blockers_of, Content, ContentQueryView, Hit, HitFacts, RetryPanel, Status, Target, VersionRow,
};
use crate::pages::download::Download;
/// M191「有失败项」那一屏:两档各几个项目,面板按档分组。
///
/// **走真判据**([`RetryPanel::of`]),不手搓面板字段:出图与产品路径读同一个函数,
/// 判据一改这条演示就跟着变(否则演示图能一直画着一个已经不存在的面板)。
fn retry_failure(instance: &str, catalog: Catalog) -> Option<RetryPanel> {
use unlucky_presenter::dto::{
InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage,
};
let mut progress = InstallProgressView {
version: "Sodium".to_string(),
instance: instance.to_string(),
loader: "Fabric".to_string(),
stage: InstallStage::Failed,
files_done: 1,
files_total: 4,
..InstallProgressView::new()
};
// 三种可显示性各一条,与 host 侧 `plan::label_of` 的三种情况同形:
// 根项目 = 请求里的标题、下载失败 = 文件名、不兼容档 = 失败路径上现取的项目名。
progress.retry = vec![
InstallFailureView::new(
"AANobbMI",
"Sodium",
InstallFailureKind::Unreachable,
"下载 sodium-fabric-0.6.0.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
),
InstallFailureView::new(
"P7dR8mSH",
"Entity Culling",
InstallFailureKind::Incompatible,
"没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本",
),
InstallFailureView::new(
"1eAoo2KR",
"fabric-api-0.92.2+1.20.1.jar",
InstallFailureKind::Unreachable,
"下载 fabric-api-0.92.2+1.20.1.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
),
];
RetryPanel::of(&progress, catalog)
}
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
fn targets() -> Vec<Target> {
vec![
@@ -254,6 +296,12 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
} else {
None
},
// M191:`retryfail` 画「有失败项」,`retryok` 画「全部成功」——后者走同一条判据,
// 得到 `None`(连按钮都不渲染)。
retry: match state.as_str() {
"retryfail" => retry_failure("survival", catalog),
_ => None,
},
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
available_versions: demo_versions,
versions_note: None,
@@ -265,3 +313,33 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
pack,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 「有失败项」那一屏的两档走**真判据**(`RetryPanel::of`),不是手搓的面板字段。
#[test]
fn the_failure_screen_shows_both_tiers() {
let panel = retry_failure("survival", Lang::En.catalog()).expect("演示必须有面板");
assert_eq!(panel.total, 3);
assert!(panel.has_both_tiers(), "{:?}", panel.groups);
assert_eq!(
panel.items_of(unlucky_presenter::dto::InstallFailureKind::Unreachable),
Some(
[
// 根项目:名字来自请求标题
"Sodium (AANobbMI)".to_string(),
// 下载失败:名字来自文件名
"fabric-api-0.92.2+1.20.1.jar (1eAoo2KR)".to_string(),
]
.as_slice()
)
);
// 不兼容档:失败路径上现取的项目名(不是 id)
assert_eq!(
panel.items_of(unlucky_presenter::dto::InstallFailureKind::Incompatible),
Some(["Entity Culling (P7dR8mSH)".to_string()].as_slice())
);
}
}
@@ -37,6 +37,11 @@ pub enum Message {
Retry,
/// 装进目标实例
Install,
/// **只重试上次失败的那几项**(M191;不是「整单重装」)
///
/// app 层的落点:`AppCommand::InstallContent` 带 `retry: true`——host 拿上一轮的依赖计划
/// 调 `DependencyPlan::retry_request()`,只重解失败项。
RetryFailed,
/// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`)
Pack(super::packui::PackMessage),
}
@@ -32,10 +32,12 @@ use unlucky_i18n::{Catalog, Key};
use crate::pages::download::Download;
mod items;
mod retry;
pub(crate) use items::HitFacts;
pub(crate) use items::{capitalize, thousands};
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow};
pub use retry::{RetryGroup, RetryPanel};
/// 内容库页要画的东西。
#[derive(Debug, Clone, PartialEq)]
@@ -69,6 +71,11 @@ pub struct Content {
/// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树
/// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。
pub panel: Option<Download>,
/// 「只重试这些」面板(M191):`None` = 不渲染(全成功 / 没有失败项 / 还在跑)。
///
/// 与 `panel` 互斥地画:失败且有可重试项时,这块**顶替**下载页那块通用失败面板,
/// 一屏只有一个失败块(两个会说同一件事,还各自给一个「关闭」)。
pub retry: Option<RetryPanel>,
/// 游戏版本筛选的候选(来自实例;去重、保持实例顺序)
pub versions: Vec<String>,
/// 关键词草稿
@@ -187,6 +194,11 @@ impl Content {
target_label,
blockers,
panel: content_install(state, catalog),
// M191:失败且有可重试项时这块才存在(全成功 ⇒ `None` ⇒ 视图里连按钮都没有)
retry: state
.install
.as_ref()
.and_then(|progress| RetryPanel::of(progress, catalog)),
versions,
draft: page.draft.clone(),
pack,
@@ -0,0 +1,153 @@
//! 「只重试这些」面板的视图模型(M191)。
//!
//! ## 它画的是什么
//!
//! 安装失败之后,界面要说清「哪几项没装上、分别是哪种没装上」:
//!
//! ```text
//! 安装未完成 —— 3 项没装上
//! 不兼容所选版本/加载器
//! P7dR8mSH ← 拿不到名字时只画 id(合理兜底)
//! 取不到(网络 / 项目不存在)
//! Sodium (AANobbMI) ← 根项目:名字来自请求标题
//! fabric-api-0.92.2.jar (1eAoo2KR) ← 下载失败:名字来自文件名
//! [只重试这 3 个] [关闭]
//! ```
//!
//! 每行是 `InstallFailureView::line()` 的结果(名字优先、id 次要)。名字是**语言中立**的站点
//! 标题或文件名,不是 core 的中文散文——那条线(i18n R2)踩了就回不去。
//!
//! ## 三条判据(都落在 [`RetryPanel::of`],纯函数,用例直接钉)
//!
//! 1. **全成功不渲染**:`retry` 名单空 ⇒ `None` ⇒ 视图里连按钮都不存在(不是置灰);
//! 2. **两档不混**:分组按 [`InstallFailureKind`] 分开,两档各有自己的标题 key;
//! 3. **只列名单里的**:条目原样来自 DTO(`presenter` 从 `DependencyPlan::retry_request()`
//! 的同一份失败名单产出),页面不自己挑、也不补「顺便重装根」。
//!
//! ## 为什么这里不拼文案
//!
//! 分组标题是 [`Key`](i18n),条目是项目键(站点数据,不是文案)。拼一句带量的中文句子
//! 会把语序写死——英文与中文的量词位置不同(`Retry just these 3` / `只重试这 3 个`)。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
ContentInstallRequest, ContentSource, InstallFailureKind, InstallFailureView,
InstallProgressView, InstallStage, LoaderKind,
};
/// 面板上的一个档位分组。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetryGroup {
/// 哪一档(顺序固定,见 [`InstallFailureKind::ALL`])
pub kind: InstallFailureKind,
/// 这一档的标题 key(不硬编码中文)
pub title: Key,
/// 这一档要画的每一行(`名字 (id)`;拿不到名字时就只有 id,顺序 = 计划里的顺序)
pub items: Vec<String>,
}
/// 「只重试这些」面板(`None` = 不渲染:没有失败项、或已经全成功)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetryPanel {
/// 失败的那次装入的目标实例(重试只对这个实例生效)
pub instance: String,
/// 标题(已按词表渲染:带条数)
pub title: String,
/// 主按钮文字(已按词表渲染:`只重试这 N 个`)
///
/// 存在这里而不是画的时候现拼:`Button::new` 要的是 `&'a str`,而词表渲染出来的是
/// `String`——存进模型(与 `Hit` / `VersionRow.line` 同一手法)就不必为了一个标签
/// 去改组件库的入参类型。
pub action: String,
/// 按档分组(空组不出现)
pub groups: Vec<RetryGroup>,
/// 失败项总数(标题与按钮都用这个数)
pub total: usize,
}
impl RetryPanel {
/// 进度快照 → 面板(**唯一**判据;`None` = 视图里不该出现这块)。
///
/// 只认 `Failed` 这一态:`Done` 没有可重试的东西,`Cancelled` 是用户自己停的
/// (那条路上根本没有失败名单),`Planning`/`Downloading` 还在跑。
#[must_use]
pub fn of(progress: &InstallProgressView, catalog: Catalog) -> Option<Self> {
if progress.stage != InstallStage::Failed || !progress.has_retry() {
return None;
}
let total = progress.retry.len();
let count = total.to_string();
let groups = InstallFailureKind::ALL
.into_iter()
.map(|kind| RetryGroup {
kind,
title: title_of(kind),
items: progress
.retry_of(kind)
.map(InstallFailureView::line)
.collect(),
})
.filter(|group| !group.items.is_empty())
.collect();
Some(Self {
instance: progress.instance.clone(),
title: catalog.render(Key::ContentInstallFailedTitle, &[("count", count.as_str())]),
action: catalog.render(Key::ContentInstallRetryOnly, &[("count", count.as_str())]),
groups,
total,
})
}
/// 两档都在吗(「分组」这件事本身的判据:都出现时界面上是两段,不是一段)。
#[must_use]
pub fn has_both_tiers(&self) -> bool {
self.groups.len() == 2
}
/// 某一档的条目(用例断言用)。
#[must_use]
pub fn items_of(&self, kind: InstallFailureKind) -> Option<&[String]> {
self.groups
.iter()
.find(|group| group.kind == kind)
.map(|group| group.items.as_slice())
}
}
/// 档位 → 标题 key(**穷尽 match**:合并两档会少一条文案,用例会红)。
#[must_use]
pub const fn title_of(kind: InstallFailureKind) -> Key {
match kind {
InstallFailureKind::Incompatible => Key::ContentInstallRetryIncompatible,
InstallFailureKind::Unreachable => Key::ContentInstallRetryUnreachable,
}
}
impl super::Content {
/// 「只重试这些」那一次请求(M191;`None` = 没有可重试的面板 ⇒ 这个动作不存在)。
///
/// 载荷里 `retry: true` 是**唯一**的语义开关:host 收到它就不解根项目,而是拿上一轮的计划
/// 调 `DependencyPlan::retry_request()`(只请求失败的那几个项目)。其余字段照旧填,
/// 是因为 `ContentInstallRequest` 的判据仍要能读(`project` / `title` 也用于日志);
/// 这些字段在重试路径上**不被 host 读取**——过滤条件与落盘目录都来自那份计划。
///
/// 实例取**失败那次的**实例,不是页面上此刻选中的那个:用户可能已经换了目标实例。
pub fn retry_install_request(&self) -> Option<ContentInstallRequest> {
let retry = self.retry.as_ref()?;
let hit = self.selected_hit();
let target = self
.targets
.iter()
.find(|target| target.id == retry.instance);
Some(ContentInstallRequest {
project: hit.map(|hit| hit.id.clone()).unwrap_or_default(),
title: hit.map(|hit| hit.title.clone()).unwrap_or_default(),
kind: self.kind,
source: hit.map_or(ContentSource::Modrinth, |hit| hit.source),
instance: retry.instance.clone(),
game_version: target.map_or_else(String::new, |target| target.mc_version.clone()),
loader: target.map_or(LoaderKind::Vanilla, |target| target.loader),
retry: true,
})
}
}
@@ -355,3 +355,10 @@ fn loader_names_are_ascii_so_the_english_baseline_stays_english() {
"不能画「原版」"
);
}
// M191:安装失败的两档分组与「只重试这些」(按主题分卷,父文件已贴 400 行硬线)
//
// `#[path]` 显式给出:本文件自己是 `#[path = "model/tests.rs"]` 挂上去的,子模块默认按
// 「父模块所在目录」找,会指到 `model/retry.rs`(视图模型本体)而不是这里的 `tests/retry.rs`。
#[path = "tests/retry.rs"]
mod retry;
@@ -0,0 +1,158 @@
//! M191:安装失败的两档分组 + 「只重试这些」(`super` 按主题分卷,父文件已贴 400 行硬线)。
//!
//! 三条变异守门:两档不许合并 / 全成功不许渲染 / 重试只针对失败那次的实例。
use super::super::{Content, PageState, RetryPanel};
use super::{hit, results, state_with};
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{
InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage,
};
/// 一份「两档都有」的失败进度快照。
fn failed_progress() -> unlucky_presenter::dto::InstallProgressView {
let mut progress = InstallProgressView {
version: "Sodium".to_string(),
instance: "survival".to_string(),
stage: InstallStage::Failed,
files_done: 1,
files_total: 4,
..InstallProgressView::new()
};
// 三种可显示性各一条(与 `plan::label_of` 的三种情况同形):
// 根项目用请求里的标题、下载失败用文件名、拿不到名的只画 id。
progress.retry = vec![
InstallFailureView::new(
"AANobbMI",
"Sodium",
InstallFailureKind::Unreachable,
"连不上 CDN",
),
InstallFailureView::new(
"P7dR8mSH",
"Entity Culling",
InstallFailureKind::Incompatible,
"没有匹配的版本",
),
InstallFailureView::new("1eAoo2KR", "", InstallFailureKind::Unreachable, "HTTP 404"),
];
progress
}
/// 两档**分组**画,各有自己的标题;条目按档分开,不混成一段。
///
/// 变异守门②:把两档合并(`kind_of` 都映成 `Unreachable`,或 `title_of` 都返回同一个 key)
/// ⇒ `groups.len()` 变 1 / 两个 title 相同 ⇒ 这条红。
#[test]
fn the_two_tiers_are_two_groups_with_two_labels() {
let panel = RetryPanel::of(&failed_progress(), Lang::En.catalog()).expect("有失败项就有面板");
assert_eq!(panel.total, 3);
assert!(panel.has_both_tiers(), "{:?}", panel.groups);
// **P2-1 的守门**:有名字的画「名字 (id)」,没有名字的才只画 id
assert_eq!(
panel.items_of(InstallFailureKind::Incompatible),
Some(["Entity Culling (P7dR8mSH)".to_string()].as_slice())
);
assert_eq!(
panel.items_of(InstallFailureKind::Unreachable),
Some(["Sodium (AANobbMI)".to_string(), "1eAoo2KR".to_string()].as_slice())
);
// 变异守门:把标签置空 ⇒ 这一行退回裸 id,上面两条立刻红
assert!(
panel
.items_of(InstallFailureKind::Unreachable)
.is_some_and(|items| items[0].starts_with("Sodium")),
"根项目必须显示请求里的标题,不是 id"
);
let titles: Vec<Key> = panel.groups.iter().map(|group| group.title).collect();
assert_ne!(titles[0], titles[1], "两档的标题 key 不许是同一个");
// 文案来自词表(不是硬编码中文):英文目录下这两句必须不同且不含中文
let incompatible = Lang::En.catalog().text(titles[0]);
let unreachable = Lang::En.catalog().text(titles[1]);
assert_ne!(incompatible, unreachable);
assert!(incompatible.is_ascii(), "{incompatible}");
assert!(unreachable.is_ascii(), "{unreachable}");
// 按钮文字带条数,也来自词表
assert!(panel.action.contains('3'), "{}", panel.action);
assert!(panel.action.is_ascii(), "{}", panel.action);
}
/// 只有一档失败时只画一段(空组不出现——空的「取不到」标题会让用户以为那里也坏了)。
#[test]
fn an_empty_tier_does_not_get_a_group() {
let mut progress = InstallProgressView {
stage: InstallStage::Failed,
..InstallProgressView::new()
};
progress.retry = vec![InstallFailureView::new(
"P7dR8mSH",
"Entity Culling",
InstallFailureKind::Incompatible,
"没有匹配的版本",
)];
let panel = RetryPanel::of(&progress, Lang::En.catalog()).expect("有失败项就有面板");
assert!(!panel.has_both_tiers());
assert!(panel.items_of(InstallFailureKind::Unreachable).is_none());
}
/// **全成功时不渲染**:`Done` 且没有失败项 ⇒ 面板不存在(不是置灰,是连构造都没有)。
///
/// 变异守门③:把 `RetryPanel::of` 的判据去掉(恒返回 `Some`)⇒ 这条红。
#[test]
fn a_fully_successful_install_has_no_retry_surface_at_all() {
let done = InstallProgressView {
version: "Sodium".to_string(),
instance: "survival".to_string(),
stage: InstallStage::Done,
files_done: 4,
files_total: 4,
..InstallProgressView::new()
};
assert!(RetryPanel::of(&done, Lang::En.catalog()).is_none());
// 页面上也一样:状态是 Done ⇒ 页底那块既没有重试面板,也没有失败面板
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
state.install = Some(done);
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert!(model.retry.is_none(), "全成功还在页面上画重试面板");
assert!(model.panel.is_none());
assert!(model.retry_install_request().is_none());
// 判据是**两半**:`Failed` **且** 名单非空。第一半单独钉住——就算快照里残留了名单
// (不该发生,host 成功时会清),`Done` 也不画(r1 复审指出:只去掉这一半曾全绿)。
let mut stale = InstallProgressView {
stage: InstallStage::Done,
..InstallProgressView::new()
};
stale.retry = failed_progress().retry;
assert!(
RetryPanel::of(&stale, Lang::En.catalog()).is_none(),
"Done 态不许画重试面板(判据是「失败且有名单」,不是「有名单」)"
);
// 第二半:失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西
let mut failed = InstallProgressView {
stage: InstallStage::Failed,
..InstallProgressView::new()
};
failed.retry.clear();
assert!(RetryPanel::of(&failed, Lang::En.catalog()).is_none());
}
/// 「只重试这些」发出去的是**失败那次的实例** + `retry: true`(不是页面此刻选中的实例,
/// 也不是一次新的根项目安装)。
#[test]
fn the_retry_action_targets_the_failed_attempt() {
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
// 名单要有,否则这块入口根本不存在
state.install = Some(failed_progress());
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert!(model.retry.is_some());
let request = model.retry_install_request().expect("有面板就该有请求");
assert!(
request.retry,
"重试必须是 retry: true(否则 host 会从根解起)"
);
assert_eq!(request.instance, "survival");
}
@@ -19,9 +19,10 @@ use std::path::PathBuf;
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, ImportState,
ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportCancelView,
ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView,
ImportState, ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView,
PackFormatView,
};
use crate::pages::content::Target;
@@ -212,6 +213,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
bytes_written: 3_400_000,
rolled_back: true,
failure_stage: None,
cancel: ImportCancelView::NotRequested,
};
if conflicts {
report.conflicts = vec![
@@ -196,8 +196,8 @@ pub struct Pack {
pub running: Option<Running>,
/// 探测失败诊断
pub probe: Option<Probe>,
/// 用户取消过这次导入:面板收回但**后台还在跑**(core 没有取消令牌),
/// 所以要把这句话留在界面上,别让用户以为「没建成」(复审 r1 P1-2)。
/// 用户点过取消、**结果还没回来**(面板已收回)。它只作占位:真实结局在
/// `report.cancel` 里(已回滚 / 回滚没做完 / 取消太晚),所以文案不许在这里替结局下结论。
pub cancelled: bool,
/// 取消提示的正文
pub cancelled_note: &'static str,
@@ -100,6 +100,8 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
eta_secs: 0,
// 整合包导入的失败不产生内容库分类(它有自己的报告视图)。
failure_kind: None,
// 导入没有「只重试这些」这条路径(重试整个导入由导入区自己管)。
retry: Vec::new(),
message: progress.current.clone().unwrap_or_default(),
}
}
@@ -14,7 +14,7 @@
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView,
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportCancelView, ImportErrorView,
ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView,
};
@@ -124,6 +124,11 @@ pub struct Report {
pub can_overwrite: bool,
/// 「两者都留改名」为何不支持(有冲突时如实写)
pub overwrite_note: Option<String>,
/// 这次运行与取消的关系(M195 r3:标题与图标按它分档,不许一句打天下)
pub cancel: ImportCancelView,
/// 取消的**补充说明**:只有「取消太晚」有——那种情况标题照常是"已导入/部分成功",
/// 必须另起一句说清"你的取消没赶上,实例会保留、同名文件可能已被覆盖"。
pub cancel_note: Option<String>,
}
impl Report {
@@ -202,6 +207,16 @@ pub(super) fn report_of(
&& page.instance.is_some(),
overwrite_note: (!report.conflicts.is_empty())
.then(|| catalog.text(Key::PackOverwriteNote).to_owned()),
cancel: report.cancel,
// 「取消太晚」那句只说给**真的跑完**的导入(复审 r3 P2-1):推导那边已经保证
// `Failed` 不会落到 `TooLate`,这里再看一眼状态是双保险——万一推导回归,也不会让
// 红卡("零残留")与"已经跑完、文件可能被覆盖"同时出现在一屏上。
cancel_note: (report.cancel == ImportCancelView::TooLate
&& matches!(
report.status,
ImportStatusView::Success | ImportStatusView::Partial
))
.then(|| catalog.text(Key::PackCancelTooLate).to_owned()),
}
}
@@ -215,6 +230,12 @@ fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str {
match report.status {
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
// 三档取消各有各的话:已回滚(什么都没留下)/ 回滚没做完(要对一眼目录)/
// 取消太晚(标题是真实状态,另有一句 cancel_note 说明"没赶上")。
ImportStatusView::Cancelled => match report.cancel {
ImportCancelView::RollbackIncomplete => catalog.text(Key::PackCancelIncomplete),
_ => catalog.text(Key::PackStatusCancelled),
},
ImportStatusView::Failed => match report.failure_stage {
Some(ImportFailureStageView::NotStarted) => {
catalog.text(Key::PackStatusFailedNotStarted)
@@ -3,6 +3,7 @@
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
mod cancel;
mod pick;
mod stage;
@@ -11,8 +12,9 @@ use std::path::PathBuf;
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
ExportPreviewView, ImportErrorView, ImportProgressView, ImportReportView, ImportStageView,
ImportStatusView, InstallStage, MissingFileView, MissingReasonView, PackFormatView,
ExportPreviewView, ImportCancelView, ImportErrorView, ImportProgressView, ImportReportView,
ImportStageView, ImportStatusView, InstallStage, MissingFileView, MissingReasonView,
PackFormatView,
};
use super::progress::{panel_progress, panel_stage, stage_line};
@@ -228,6 +230,7 @@ fn report_keeps_tolerable_and_fatal_apart() {
bytes_written: 0,
rolled_back: true,
failure_stage: None,
cancel: ImportCancelView::NotRequested,
};
let block = report_of(&report, &PackPageState::default(), catalog());
assert!(block.fatal.is_some(), "致命块必须单独存在");
@@ -265,6 +268,7 @@ fn conflicts_default_to_keeping_the_user_copy() {
bytes_written: 0,
rolled_back: true,
failure_stage: None,
cancel: ImportCancelView::NotRequested,
};
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
let page = PackPageState {
@@ -363,6 +367,7 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
bytes_written: 0,
rolled_back: true,
failure_stage: None,
cancel: ImportCancelView::NotRequested,
};
let new_instance = PackPageState::default();
@@ -380,21 +385,3 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
"导入到已有实例时才给翻档动作"
);
}
#[test]
fn cancelled_state_is_visible_and_says_the_import_keeps_running() {
// 复审 r1 P1-2:面板收回之后,界面上必须留一句「后台还在跑」
let import = unlucky_presenter::dto::ImportState {
cancelled: true,
..unlucky_presenter::dto::ImportState::default()
};
let export = unlucky_presenter::dto::ExportState::default();
let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog());
assert!(model.cancelled);
assert!(
model.cancelled_note.to_lowercase().contains("background"),
"取消提示必须说清后台还在跑:{}",
model.cancelled_note
);
assert!(!model.is_idle(), "取消态不能被当成「什么都没有」");
}
@@ -0,0 +1,140 @@
//! 取消的**三档真实结局**在界面上的分支(M195 r3)。
//!
//! 口径:一句「已停手并回滚」打天下是假话——取消可能停在提交前(已回滚)、可能停在回滚没收尾
//! (目标目录可能变了)、也可能**太晚**(管线已经跑完,实例保留、同名文件可能已被覆盖)。
//! 这一族用例钉的就是"三档各说各的话",以及"占位文案不再替结局下结论"。
use std::path::PathBuf;
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{
ImportCancelView, ImportReportView, ImportStatusView, PackFormatView,
};
use super::super::report::report_of;
use super::super::PackPageState;
fn catalog() -> Catalog {
Lang::En.catalog()
}
fn report(
status: ImportStatusView,
cancel: ImportCancelView,
rolled_back: bool,
) -> ImportReportView {
ImportReportView {
status,
format: Some(PackFormatView::Multimc),
pack_name: Some("Better MC".to_string()),
pack_version: None,
game_version: Some("1.20.1".to_string()),
loader: None,
version_id: Some("1.20.1".to_string()),
target: PathBuf::from("C:/tmp/instance"),
files: Vec::new(),
conflicts: Vec::new(),
missing: Vec::new(),
rejected: Vec::new(),
warnings: Vec::new(),
error: None,
registered: None,
bytes_written: 0,
rolled_back,
failure_stage: None,
cancel,
}
}
#[test]
fn a_rolled_back_cancel_says_it_is_cancelled_not_partially_installed() {
// 取消停在提交前:已回滚 ⇒ 标题是「已取消 —— 已回滚」,**不是**「部分成功」(那等于说装了一半),
// 也不能出现「取消太晚」那句补充说明。
let view = report(
ImportStatusView::Cancelled,
ImportCancelView::RolledBack,
true,
);
let block = report_of(&view, &PackPageState::default(), catalog());
assert_eq!(block.title, catalog().text(Key::PackStatusCancelled));
assert_ne!(
block.title,
catalog().text(Key::PackStatusPartial),
"已回滚的取消不许显示成「部分成功」"
);
assert!(!block.installed(), "取消不是「装上了」");
assert!(block.cancel_note.is_none(), "已回滚不需要「太晚」那句");
}
#[test]
fn an_incomplete_cancel_rollback_says_the_directory_may_differ() {
let view = report(
ImportStatusView::Cancelled,
ImportCancelView::RollbackIncomplete,
false,
);
let block = report_of(&view, &PackPageState::default(), catalog());
assert_eq!(block.title, catalog().text(Key::PackCancelIncomplete));
assert_ne!(
block.title,
catalog().text(Key::PackStatusCancelled),
"回滚没做完 ≠ 已回滚(这两句话不能混)"
);
}
#[test]
fn a_too_late_cancel_says_the_import_already_finished() {
// 取消来得太晚:状态是真实的 `Success`,另加一句「实例会保留、同名文件可能已被覆盖」。
let view = report(ImportStatusView::Success, ImportCancelView::TooLate, false);
let block = report_of(&view, &PackPageState::default(), catalog());
assert_eq!(block.title, catalog().text(Key::PackStatusSuccess));
assert_eq!(block.status, ImportStatusView::Success);
assert_ne!(
block.status,
ImportStatusView::Cancelled,
"太晚 ≠ 已回滚(状态是真的 Success/Partial)"
);
assert_eq!(
block.cancel_note,
Some(catalog().text(Key::PackCancelTooLate).to_owned()),
"必须另外说清「取消没赶上」"
);
}
/// 三档的标题**互不相同**(把任意两档并成一句,这里就红)。
#[test]
fn the_three_cancel_outcomes_have_three_different_titles() {
let titles = [
report_of(
&report(
ImportStatusView::Cancelled,
ImportCancelView::RolledBack,
true,
),
&PackPageState::default(),
catalog(),
)
.title,
report_of(
&report(
ImportStatusView::Cancelled,
ImportCancelView::RollbackIncomplete,
false,
),
&PackPageState::default(),
catalog(),
)
.title,
report_of(
&report(ImportStatusView::Success, ImportCancelView::TooLate, false),
&PackPageState::default(),
catalog(),
)
.title,
];
let unique: std::collections::BTreeSet<&String> = titles.iter().collect();
assert_eq!(unique.len(), 3, "三档必须各说各的话:{titles:?}");
}
@@ -3,7 +3,7 @@
//! 从 `tests.rs` 拆出(那边贴着 400 行硬线):本文件只守「预检没过(一个字节都没写)」与
//! 「已回滚」的措辞/图标分野。夹具在父模块(`use super::*`)。
use unlucky_presenter::dto::ImportFailureStageView;
use unlucky_presenter::dto::{ImportCancelView, ImportFailureStageView};
use super::*;
@@ -39,6 +39,7 @@ fn the_two_failure_stages_never_share_one_sentence() {
bytes_written: 0,
rolled_back: true,
failure_stage: stage,
cancel: ImportCancelView::NotRequested,
}
}
@@ -98,7 +98,8 @@ pub(crate) fn section<'a>(
} else if let Some(report) = &model.report {
block = block.push(report_block(ui, catalog, report));
} else if model.cancelled {
// 取消过、还没有新结果:把「后台还在跑」这句话留在屏上(复审 r1 P1-2)
// 取消过、**结果还没回来**:先留一句"正在停手"的占位(真实结局随后的报告给出,
// 已回滚 / 回滚没做完 / 取消太晚三档见 `model::report`)
block = block.push(
column![
frame::text_role(
@@ -57,6 +57,14 @@ pub(super) fn report_block<'a>(
report.written_line.as_str(),
ui.color(Role::OnSurfaceVariant),
),
// 「取消太晚」的那句补充说明(标题仍是真实的 `已导入 / 部分成功`)。
frame::text_role(
ui,
TypeRole::BodySmall,
false,
report.cancel_note.as_deref().unwrap_or(""),
ui.color(Role::Tertiary),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
@@ -292,17 +300,31 @@ pub(super) fn conflict_row<'a>(
}
pub(super) fn status_icon(report: &Report) -> IconKind {
use unlucky_presenter::dto::{ImportCancelView, ImportStatusView};
match report.status {
unlucky_presenter::dto::ImportStatusView::Success => IconKind::Check,
unlucky_presenter::dto::ImportStatusView::Partial => IconKind::Warn,
unlucky_presenter::dto::ImportStatusView::Failed => IconKind::Error,
ImportStatusView::Success => IconKind::Check,
ImportStatusView::Partial => IconKind::Warn,
ImportStatusView::Failed => IconKind::Error,
// 取消**不是错误**(M195):停手用「关闭(叉)」这个描边字形(页面骨架只允许描边,
// 令牌层的 Play/Stop/More 是填充字形,见 `tests/no_hardcoded_tokens.rs` 的守门),
// 回滚没做完才升级成 Warn,都不上红。
ImportStatusView::Cancelled => match report.cancel {
ImportCancelView::RollbackIncomplete => IconKind::Warn,
_ => IconKind::Close,
},
}
}
pub(super) fn status_role(report: &Report) -> Role {
use unlucky_presenter::dto::{ImportCancelView, ImportStatusView};
match report.status {
unlucky_presenter::dto::ImportStatusView::Success => Role::Primary,
unlucky_presenter::dto::ImportStatusView::Partial => Role::Tertiary,
unlucky_presenter::dto::ImportStatusView::Failed => Role::Error,
ImportStatusView::Success => Role::Primary,
ImportStatusView::Partial => Role::Tertiary,
ImportStatusView::Failed => Role::Error,
// 取消一档也不做成 `Role::Error`:用户自己点的,不该看起来像装坏了。
ImportStatusView::Cancelled => match report.cancel {
ImportCancelView::RollbackIncomplete => Role::Tertiary,
_ => Role::OnSurfaceVariant,
},
}
}
@@ -30,9 +30,11 @@ use super::model::{Content, Hit};
mod detail;
mod filters;
mod retry;
pub(crate) use detail::detail;
pub(crate) use filters::{filters, labelled};
pub(crate) use retry::maybe as retry_panel;
use super::Message as ContentMessage;
use crate::pages::frame;
@@ -0,0 +1,140 @@
//! 安装失败后的「只重试这些」面板(M191)。
//!
//! ## 为什么这块画在内容库,而不是复用下载页那块失败面板
//!
//! 下载页的失败面板讲的是「停在哪一步 + 打开日志」,而这里要讲的是**哪几项没装上、属于哪种
//! 没装上、以及能不能只重试它们**。两件事混在一个块里会得到「失败 + 一堆条目 + 两个关闭」。
//! 所以:有可重试名单时([`RetryPanel::of`] 给了 `Some`)这块**顶替**通用失败面板,
//! 一屏只有一个失败块。
//!
//! ## 三个控件行为
//!
//! | 控件 | 消息 | 落点 |
//! |---|---|---|
//! | 只重试这 N 个 | [`ContentMessage::RetryFailed`] | `AppCommand::InstallContent{retry: true}`(host 只重解失败项) |
//! | 关闭 | [`PageMessage::CancelInstall`] | 清 install 快照(对没在跑的安装是安全 no-op,与下载页同一个出口) |
//!
//! 两个按钮都**按不动就不画**:没有名单时整块不存在(不是画一个灰按钮)。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::theme::Ui;
use super::super::model::{Content, RetryPanel};
use super::intent;
use crate::pages::content::Message as ContentMessage;
use crate::pages::frame;
use crate::pages::Message as PageMessage;
/// 整块面板(`model.retry` 为 `None` 时调用方不该调它——判据在模型里,不在这里)。
pub(crate) fn retry_panel<'a>(
ui: &'a Ui,
catalog: Catalog,
panel: &'a RetryPanel,
) -> Element<'a, PageMessage> {
let head = row![
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
panel.title.as_str(),
ui.color(Role::Error),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
let mut body = column![head]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 两档分成两段:**各自的标题 + 各自的条目**(合并成一段就说不出「换版本还是检查网络」)。
// 条目之间用 ASCII 逗号:项目键是站点数据,句读不是文案,不该在这里挑中文的「、」。
for group in &panel.groups {
let items = group.items.join(", ");
body = body.push(
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
true,
catalog.text(group.title),
ui.color(Role::OnErrorContainer),
),
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
items,
ui.color(Role::OnErrorContainer),
),
]
.spacing(ui.space(Space::X1)),
);
}
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentInstallRetryHint),
ui.color(Role::OnErrorContainer),
));
let actions = row![
// 主按钮:只重试这些(这个块存在的**唯一**理由)
buttons::filled(
ui,
Button::new(panel.action.as_str(), intent(ContentMessage::RetryFailed))
.leading(IconKind::Refresh),
),
buttons::text(
ui,
// 复用下载页那条现有的「关闭」:同一句话不另立一条要同步的 key(r1 复审 N2)
Button::new(
catalog.text(Key::DownloadDismiss),
PageMessage::CancelInstall
),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
body = body.push(actions);
container(body)
.width(Length::Fill)
.padding(ui.space(Space::X4))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 整页底部那块(判据在 [`Content::retry`];`None` ⇒ 一帧都不画)。
pub(crate) fn maybe<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Option<Element<'a, PageMessage>> {
model
.retry
.as_ref()
.map(|panel| retry_panel(ui, catalog, panel))
}
@@ -85,3 +85,16 @@ fn the_demo_selection_is_always_one_of_the_hits() {
);
}
}
/// M191:「全部成功」那一屏(默认态)**没有**重试面板——出图与判据同源,不是手搓的。
#[test]
fn the_default_demo_screen_has_no_retry_surface() {
for kind in ContentKind::ALL {
let model = demo(kind, Lang::En);
assert!(
model.retry.is_none(),
"{} 默认态不该有重试面板",
kind.slug()
);
}
}
+6 -1
View File
@@ -98,7 +98,12 @@ fn body<'a>(
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill);
if let Some(panel) = &model.panel {
// M191:有可重试的失败项时,这块**顶替**下载页那块通用失败面板(一屏只有一个失败块);
// 没有名单时它连构造都不存在(`None`),不是画一个灰按钮。
let retry = parts::retry_panel(ui, catalog, model);
if let Some(panel) = retry {
stack = stack.push(divider::full(ui)).push(panel);
} else if let Some(panel) = &model.panel {
// **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。
stack = stack
.push(divider::full(ui))
@@ -297,6 +297,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
message: String::new(),
// 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。
failure_kind: None,
// 同理:没有失败名单 ⇒ 内容库那块「只重试这些」不出现(下载页也不画它)。
retry: Vec::new(),
},
catalog,
)
+9 -3
View File
@@ -143,6 +143,7 @@ use iced::{Element, Length};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space;
use crate::app::components::motion;
use crate::theme::Ui;
use frame::filled;
@@ -243,14 +244,19 @@ fn render<'a>(
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
match update::banner(ui, &model.update, model.lang.catalog()) {
let tree: Element<'a, Message> = match update::banner(ui, &model.update, model.lang.catalog()) {
Some(banner) => column![banner, page]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill)
.into(),
None => page,
}
};
// M192:主体入场(首屏 / 换页 / 列表↔详情)。**这里只采样**——「标识变了就起跑」在 update 侧
// (`App::sync_motion`):iced 先算订阅表后建视图,在 view 里插轨道当拍订阅不上(复审 P1-1)。
// 静息时位移恒为 `(0, 0)`,`shifted` 常驻的那一层零内距,与没有动效时逐像素相同。
motion::shifted(tree, motion::sample())
}
/// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。
@@ -268,7 +274,7 @@ pub fn app<'a>(
/// 同 [`app`],但可以带上**外观运行期状态**。
///
/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section.rs`);
/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section/`);
/// `None` ⇒ 画演示行,与 [`app`] 逐像素等价。harness 的 `--appearance=real` 走的就是这一支:
/// M100 之前 harness 恒传 `None`,于是 D30 要的「带真实外观控件」那一屏**根本不存在**——
/// 报告上写着「设置页已接真控件」,图里却只有演示行(U1 的形状)。
@@ -5,15 +5,16 @@
//! | 这里 | 内容 |
//! |---|---|
//! | [`GROUP`] | 左列名 / 图标 / 演示行(产品路径走 [`super::ai::view::body`]) |
//! | [`AI_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update 同一条**按下标分发**:本组要 i18n 文案与 `Lang` 形参,`Group.body` 的签名里没有它) |
//! | [`AI_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update 同一条**按下标分发**:本组尚未迁到 `Group.body` 槽位,见下节) |
//! | [`Message`] | 本线的消息口(落点 `app/update/settings/ai_about.rs`) |
//! | [`State`] | 转发 `ai::State`(`Groups.ai_about` 的载荷类型不变,`settings/state.rs` 一行不用改) |
//!
//! ## 为什么 `body` 是 `None` 而不是 `Some(..)`
//!
//! `Group.body` 的签名是 `fn(&Ui, States) -> Option<Element>`:拿不到 `Lang`,也就画不出中英两套
//! 文案(本组的硬要求是「不得硬编码中文」)。M106 的语言行与 M159 的更新组是同一个理由走了
//! 按下标分发;本组沿用同一条路,代价是 `settings/mod.rs` 里多一行 `else if`。
//! `Group.body` 的槽位(M179 起)拿的是 [`States`](super::States) 与**生效语言** `Lang`——本组要的
//! 两件它其实都装得下。本组仍走按下标分发,是因为**还没迁过去**:槽位要求返回 `Option<Element>`,
//! 而 `ai::view::body` 返回 `Element`,把本组(以及 Java 组)统一迁到槽位上是一次独立重构。
//! M106 的语言行与 M159 的更新组当初也是同一个理由走上这条路,代价是 `settings/mod.rs` 多一行 `else if`。
//!
//! ## 密钥纪律
//!
@@ -5,20 +5,25 @@
//! 「Appearance」这个名字**;分组在左列的下标仍由 `crate::pages::appearance::APPEARANCE_GROUP`
//! 声明,`settings/tests/mod.rs` 拿它和本文件比对(顺序被调换会红)。
//!
//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section.rs`(本文件只负责
//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section/`(本文件只负责
//! 把它挂进注册表),所以改控件时不用碰设置页目录。
//!
//! M101 r2:本组的渲染器签名改成收 [`States`](组状态 + 外观状态),于是「这一组要读哪些状态」
//! 由本文件决定——`settings/mod.rs` 不再认识 `appearance::State`。
//!
//! M179 `I18NSEAM`:签名再加一个 `lang`(`Group.body` 的槽位本身就带语言,见 `settings/mod.rs` 的
//! `Body` 类型别名),于是这一组的中文界面文案真的走词表——此前它只能写死 `Lang::En`,
//! 因为渲染器拿不到「这一次生效的语言」。
use iced::Element;
use unlucky_design::icons::IconKind;
use unlucky_i18n::Lang;
use crate::theme::Ui;
use super::{Group, Message, States};
/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section.rs` 的真控件);
/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section/` 的真控件);
/// 没有外观运行期状态时(harness / `pages_demo`)返回 `None`,由 `form` 退回下面的演示行。
pub(super) const GROUP: Group = Group {
name: "Appearance",
@@ -33,8 +38,10 @@ pub(super) const GROUP: Group = Group {
};
/// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。
fn body<'a>(ui: &'a Ui, states: States<'a>) -> Option<Element<'a, Message>> {
///
/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西。
fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option<Element<'a, Message>> {
states
.appearance
.map(|state| crate::pages::appearance::section(ui, state))
.map(|state| crate::pages::appearance::section(ui, state, lang))
}
+11 -5
View File
@@ -4,10 +4,11 @@
//!
//! ## 这一组为什么特殊
//!
//! 它**不走** `Group::body`:那个渲染器的签名是 `fn(&Ui, States) -> Option<Element>`,`States` 只有
//! `appearance` + `groups` 两格,装不下「当前语言档位 + 生效语言」。语言行要的两件在
//! `pages::Language` 里,由 `pages::settings::view` 的 `language` 形参一路递进来、`form` 按下标
//! 转交 [`body`];所以 `GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」照样成立。
//! 它**不走** `Group::body`:那个渲染器拿的是 [`States`](super::States) 与**生效语言** `Lang`
//! (M179 起;此前连 `Lang` 都没有),而语言行要的是「当前语言**档位** `LangChoice` + 生效语言」
//! 这一对——档位不在 `States` 里。所以它由 `pages::settings::view` 的 `language` 形参一路递进来、
//! `form` 按下标转交 [`body`];`GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」
//! 照样成立。
//!
//! ## 语言切换**没有**走 M101 的 `GroupMessage` 投递口(偏离,已登记)
//!
@@ -77,6 +78,11 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
stacked = stacked.push(divider(ui));
stacked = stacked.push(font_row(ui, language));
// M188:日志文件夹(路径 + 打开 + 复制路径)。放在「主题/启动页」之前——用户投诉
// 「找不到崩溃日志」要的答案,不该埋在只读行后面。
stacked = stacked.push(divider(ui));
stacked = stacked.push(super::logs_row::folder_row(ui, &catalog));
// 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
for (label, value) in [
(Key::SettingsTheme, Key::SettingsThemeFollowSystem),
@@ -284,7 +290,7 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me
/// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。
///
/// 与 `pages/appearance/section.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。
/// 与 `pages/appearance/section/mod.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。
fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content_color = if selected {
+67 -27
View File
@@ -1,44 +1,84 @@
//! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。
//! 设置页「Java」分组(M175):运行时列表 / 默认 JDK / 内存 / JVM 参数。
//!
//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(java::body)`(渲染器也写在本文件),
//! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。
//! 本文件只剩三样东西,正文 / 状态 / 读写都在 `pages/settings/java/` 里:
//!
//! 图标说明(视觉复审 P2-3):令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架;
//! Java 分组讲的是运行时安装位置,用目录图标语义也对得上。
//! | 这里 | 内容 |
//! |---|---|
//! | [`GROUP`] | 左列名 / 图标(正文走 [`view::body`],`body` 槽位保持 `None`) |
//! | [`JAVA_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update / Assistant 同一条**按下标分发**:本组要 `Lang`,`Group.body` 的签名里没有它) |
//! | [`Message`] | 本线的消息口(落点 `app/update/settings/java.rs`) |
//!
//! ## M101 的演示行去哪了
//!
//! M101 留的 `rows: &[("Runtime","Temurin 21.0.4"), …]` 与绊线测试
//! `the_java_group_is_still_demo_rows`(断言 `body.is_none()`)在 M175 一起删掉了:
//! 那三个值是**写死的演示字符串**,用户实测把它们当成了真检测结果
//! (「它默认显示 Temurin 21.0.4 我甚至不能确定它是否是一个硬编码的值」)。
//! 现在的真值来自三段真读盘:`core::game::java::staged`(扫描)、`core::game::java::manual`
//! (手动路径严格探测)、`config/launcher.json`(`java.*` 三键)。
//!
//! `rows` 仍留成空切片:它是 `Group` 的字段(另外五组在用),而本组的 `body` 分发永远命中
//! [`view::body`],空切片不会被画出来——宁可空着,也不留一句会撒谎的演示值。
mod fields;
mod row;
mod seed;
mod state;
pub(crate) mod store;
pub(super) mod view;
#[cfg(test)]
mod tests;
pub use state::{same_path, Context, Layer, Notice, Requirement, State};
/// 测试里的注入作业要写 `Vec<RuntimeRow>`(App 层在生产路径上只用 `State`,不需要这个名字)。
#[cfg(test)]
pub use row::RuntimeRow;
use unlucky_design::icons::IconKind;
use super::Group;
/// 本组在 [`super::GROUPS`] 里的下标(`GROUPS` 的顺序是 general / appearance / **java** / …)。
///
/// 写死 2 与 `groups[2]` 同一个来源:`tests/mod.rs` 的「左列顺序」把这张表钉住了。
pub(crate) const JAVA_GROUP: usize = 2;
/// Java 分组。
pub(super) const GROUP: Group = Group {
name: "Java",
icon: IconKind::Folder,
rows: &[
("Runtime", "Temurin 21.0.4"),
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
],
// 空:产品路径与窄窗「按组堆叠」都走 `view::body`(见文件头「M101 的演示行去哪了」)。
rows: &[],
body: None,
};
/// Java 分组的运行期状态(M101 r2:**只归 java 线**)。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.java`(一个字段),不认这个类型里的任何字段,
/// 所以 `settings/mod.rs` / `app/state.rs` / `app/view.rs` 都不用动。
#[derive(Debug, Default)]
pub struct State {}
/// Java 分组的消息(M101:**只归 java 线**,加变体只改本文件)。
///
/// 落点是 `app/update/settings/java.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
/// [`GroupMessage`](super::GroupMessage))。
/// 本线的消息(**只归本线**):加变体只改本文件 + `app/update/settings/java.rs`。
#[derive(Debug, Clone)]
pub enum Message {
/// **只有测试构造它**:M101 r2 留的「活的端到端证据」——外壳消息 →
/// `on_page` → `on_settings` → `on_settings_java` 这条路在运行时真的跑通,不是只靠编译期穷尽。
/// java 线接第一个真控件时把这一支换成真变体:`tests/java_section.rs` 的
/// `the_java_group_is_still_demo_rows` 就是那条会提醒你的绊线(它断言 `body.is_none()`)。
#[cfg(test)]
Ping,
/// 手动触发一次自动扫描(真扫盘,后台线程;进度与结果由心跳灌回来)
Scan,
/// 取消正在跑的扫描
CancelScan,
/// 手动路径输入框
AddDraft(String),
/// 提交手动添加(严格校验:真跑 `java -version`)
Add,
/// 把某个运行时设为默认(写 `java.selected_path`)
SelectRuntime(String),
/// 默认内存草稿(MB 文本)
EditMemory(String),
/// JVM 额外参数草稿
EditJvm(String),
/// 保存内存 + JVM 参数(校验 + 落盘 + 回读)
Save,
}
impl Message {
/// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::Java`)。
#[must_use]
pub fn into_group(self) -> super::GroupMessage {
super::GroupMessage::Java(self)
}
}
@@ -0,0 +1,246 @@
//! 「Java」分组的**编辑区**(M175):手动路径 / 默认内存 / JVM 参数 / 保存与回执。
//!
//! 从 `view.rs` 拆出来的(那个文件加了守卫之后贴到 438 行,越过 400 行硬线):这里全是
//! 「用户能改的东西」,`view.rs` 只剩「扫到了什么」与版面。两半共用 `view::msg` 这一个消息口,
//! 所以「一条控件对应哪条消息」不会各写一遍。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::pages::frame::text_role_owned;
use crate::pages::Message;
use crate::theme::Ui;
use super::state::{Layer, Notice, Refusal, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
use super::view::msg;
use super::Message as JavaMessage;
/// 手动添加一行:输入框 + 「添加」按钮 + 原因。
pub(super) fn add_row<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let field = Field::new(&state.add_draft)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsJavaAdd))
.placeholder(catalog.text(Key::SettingsJavaAddPlaceholder))
.supporting(catalog.text(Key::SettingsJavaAddHint));
let add = if state.add_draft.trim().is_empty() || !state.storage {
buttons::outlined(
ui,
Button::disabled(catalog.text(Key::SettingsJavaAddAction)),
)
} else {
buttons::outlined(
ui,
Button::new(
catalog.text(Key::SettingsJavaAddAction),
msg(JavaMessage::Add),
),
)
};
row![
container(text_field(ui, field, |value| msg(JavaMessage::AddDraft(
value
))))
.width(Length::Fill),
add,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 默认内存:数字框 + 范围 + 「生效值来自哪一层」。
pub(super) fn memory_field<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let range = catalog.render(
Key::SettingsJavaMemoryHint,
&[
("min", MIN_MEMORY_MB.to_string().as_str()),
("max", MAX_MEMORY_MB.to_string().as_str()),
],
);
let field = Field::new(&state.memory)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsJavaMemory));
// 校验不过就把输入框本身标红(不只是下面那行字):用户一眼看到「是这个框有问题」。
let field = match state.validate() {
Err(Refusal::MemoryEmpty | Refusal::MemoryNotNumber | Refusal::MemoryRange) => {
field.error()
}
_ => field,
};
let effective = match state.layer() {
Layer::Global => catalog.render(
Key::SettingsJavaLayerGlobal,
&[("mb", state.saved_memory.to_string().as_str())],
),
Layer::Instance { instance, mb } => catalog.render(
Key::SettingsJavaLayerInstance,
&[
("mb", mb.to_string().as_str()),
("instance", instance.as_str()),
],
),
};
column![
text_field(ui, field, |value| msg(JavaMessage::EditMemory(value))),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
effective,
ui.color(Role::OnSurfaceVariant),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
range,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// JVM 额外参数:文本框 + 「会拼到每次启动」的说明。
pub(super) fn jvm_field<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let field = Field::new(&state.jvm)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsJavaJvm))
.placeholder(catalog.text(Key::SettingsJavaJvmPlaceholder))
.supporting(catalog.text(Key::SettingsJavaJvmHint));
let field = if matches!(state.validate(), Err(Refusal::JvmQuotes)) {
field.error()
} else {
field
};
// 已保存值:空的时候说「还没有保存任何额外参数」,而不是让「已保存的值:」后面空着。
let saved = if state.saved_jvm.trim().is_empty() {
catalog.text(Key::SettingsJavaJvmSavedNone).to_string()
} else {
catalog.render(
Key::SettingsJavaJvmSaved,
&[("value", state.saved_jvm.as_str())],
)
};
column![
text_field(ui, field, |value| msg(JavaMessage::EditJvm(value))),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
saved,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
pub(super) fn actions_row<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let label = catalog.text(Key::SettingsJavaSave);
if state.can_save() {
buttons::filled(ui, Button::new(label, msg(JavaMessage::Save)))
} else {
buttons::filled(ui, Button::disabled(label))
}
}
/// 按钮为什么灰着 / 下一步做什么——**永远说清**。
pub(super) fn hint_line<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let (text, error) = if !state.storage {
(catalog.text(Key::SettingsJavaNoStorage).to_string(), true)
} else if state.dirty {
(catalog.text(Key::SettingsJavaUnsaved).to_string(), false)
} else {
(String::new(), false)
};
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(if error {
Role::Error
} else {
Role::OnSurfaceVariant
}),
)
}
pub(super) fn notice_line<'a>(
ui: &'a Ui,
catalog: Catalog,
notice: &Notice,
) -> Element<'a, Message> {
let (text, error) = match notice {
Notice::Saved => (catalog.text(Key::SettingsJavaSaved).to_string(), false),
Notice::Refused(reason) => (
catalog.render(
Key::SettingsJavaRefused,
&[("reason", catalog.text(refusal_key(*reason)))],
),
true,
),
Notice::Unwritable(reason) => (
catalog.render(
Key::SettingsJavaRefused,
&[("reason", catalog.text(reason.label()))],
),
true,
),
Notice::WriteFailed => (
catalog.render(
Key::SettingsJavaRefused,
&[("reason", catalog.text(Key::SettingsJavaReasonWriteFailed))],
),
true,
),
Notice::NoStorage => (catalog.text(Key::SettingsJavaNoStorage).to_string(), true),
Notice::DefaultSet(path) => (
catalog.render(Key::SettingsJavaDefaultSet, &[("path", path.as_str())]),
false,
),
Notice::Added(path) => (
catalog.render(Key::SettingsJavaAdded, &[("path", path.as_str())]),
false,
),
};
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(if error { Role::Error } else { Role::OnSurface }),
)
}
/// 拒绝原因 → 文案 key(`Refusal::label` 已是这张表,这里只是给 `match` 一个名字)。
pub(super) fn refusal_key(refusal: Refusal) -> Key {
refusal.label()
}
@@ -0,0 +1,191 @@
//! 「Java」分组的一行运行时(M175):路径 / 版本 / 厂商 / 来源 + 上屏用的截断。
//!
//! 从 `state.rs` 拆出来的(那个文件加了上下文与校验之后到 424 行,越过 400 行硬线):
//! 这里只放「一行长什么样」,状态与校验仍在 `state.rs`。
use unlucky_i18n::Key;
/// 一个扫到的 / 手动加的运行时(页面要的那几格)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeRow {
/// `bin/java[.exe]` 的绝对路径(也是 `java.selected_path` 的取值)。
pub binary: String,
/// `java -version` 的首行(探测失败时为空串)。
pub version: String,
/// 主版本号(探测不出为 `None`)。
pub major: Option<u32>,
/// 发行方(认不出为 `None`)。
pub vendor: Option<String>,
/// 来源 id(`managed` / `env` / `path` / `system` / `manual`)。
pub source: &'static str,
}
impl RuntimeRow {
/// 版本号:**能区分 `17.0.13` 与 `17.0.9`** 的那一位,而不是只给大版本(r1 复审 P2-4)。
///
/// 三层取法,逐层降级(不许因为解析不出来就只显示 `17`):
/// 1. `-version` 首行引号里的版本号(`openjdk version "21.0.11" …` → `21.0.11`);
/// 2. 无引号形态里的第一个版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`);
/// 3. 整行原样(原始版本串比一个大版本号有用);
/// 4. 连首行都没有(探测失败)→ 大版本;再没有 → `?`。
#[must_use]
pub fn version_label(&self) -> String {
if let Some(quoted) = quoted_version(&self.version) {
return quoted;
}
if let Some(bare) = bare_version(&self.version) {
return bare;
}
let line = self.version.trim();
if !line.is_empty() {
return line.to_string();
}
self.major
.map_or_else(|| "?".to_string(), |major| major.to_string())
}
/// 版本一句话:`Java 21.0.11 · Microsoft`。
#[must_use]
pub fn summary(&self) -> String {
let version = self.version_label();
match &self.vendor {
Some(vendor) => format!("Java {version} · {vendor}"),
None => format!("Java {version}"),
}
}
/// JDK 安装目录(`…/bin/java.exe` 的上一级再上一级)。
///
/// 上屏用它而不是整条可执行文件路径:424 宽的正文列放不下一条 `C:\Program Files\…\bin\java.exe`,
/// 而用户认的是 JDK 目录(也是他手动添加时会粘的那一段)。
#[must_use]
pub fn home(&self) -> String {
let path = std::path::Path::new(self.binary.trim());
let parent = path.parent();
match parent.filter(|dir| {
dir.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("bin"))
}) {
Some(bin) => bin
.parent()
.map_or_else(|| self.binary.clone(), |home| home.display().to_string()),
None => parent.map_or_else(|| self.binary.clone(), |dir| dir.display().to_string()),
}
}
/// 上屏用的目录:过长时保留**尾部**两段,前面用 `…` 收掉。
///
/// 为什么必须截:无空格的 Windows 路径在 iced 的文本排版里不可断行,一条 60 字符的路径会直接
/// 盖住右边的「设为默认」chip(M175 出图时实测到了这个重叠)。保尾部而不是开头,因为磁盘名
/// (`C:`)谁都知道,而 `…\Java\temurin\17.0.13` 才是能区分两条 JDK 的那一段。
#[must_use]
pub fn display_home(&self) -> String {
const LIMIT: usize = 39;
let home = self.home();
if home.chars().count() <= LIMIT {
return home;
}
let tail: String = home
.chars()
.rev()
.take(LIMIT - 1)
.collect::<Vec<char>>()
.into_iter()
.rev()
.collect();
// 从第一个分隔符之后开始(别让 `…` 后面接着半截目录名)。
let tail = tail
.find(['\\', '/'])
.map_or(tail.clone(), |at| tail[at + 1..].to_string());
format!("…{}{}", std::path::MAIN_SEPARATOR, tail)
}
/// 来源文案 key。
#[must_use]
pub fn source_key(&self) -> Key {
match self.source {
"managed" => Key::SettingsJavaSourceManaged,
"env" => Key::SettingsJavaSourceEnv,
"path" => Key::SettingsJavaSourcePath,
"manual" => Key::SettingsJavaSourceManual,
_ => Key::SettingsJavaSourceSystem,
}
}
}
/// 引号里的版本号(`openjdk version "21.0.11" 2025-10-21 LTS` → `21.0.11`)。
fn quoted_version(line: &str) -> Option<String> {
let start = line.find('"')? + 1;
let rest = &line[start..];
let end = rest.find('"')?;
let value = rest[..end].trim();
(!value.is_empty()).then(|| value.to_string())
}
/// 无引号形态里的版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`)。
fn bare_version(line: &str) -> Option<String> {
line.split_whitespace().find_map(|token| {
let candidate = token.trim_matches([',', '(', ')', '"']);
let mut parts = candidate.split('.');
let first = parts.next()?;
let looks_like_version = first.chars().all(|c| c.is_ascii_digit())
&& !first.is_empty()
&& parts.next().is_some();
looks_like_version.then(|| candidate.to_string())
})
}
#[cfg(test)]
mod tests {
use super::RuntimeRow;
fn row(version: &str, major: Option<u32>) -> RuntimeRow {
RuntimeRow {
binary: "C:/jdk/bin/java.exe".to_string(),
version: version.to_string(),
major,
vendor: Some("Microsoft".to_string()),
source: "system",
}
}
/// 版本号必须能区分同一大版本的两份 JDK(`17.0.13` vs `17.0.9`),不许只给 `17`。
///
/// 变异证明:把 `version_label` 换回「只用 `major`」,这条立刻红。
#[test]
fn the_version_label_keeps_the_minor_digits() {
assert_eq!(
row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).version_label(),
"17.0.13"
);
assert_eq!(
row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).version_label(),
"17.0.9"
);
assert_ne!(
row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).summary(),
row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).summary(),
"两份同大版本 JDK 的摘要必须不同"
);
assert_eq!(
row("openjdk 25.0.3 2026-04-21", Some(25)).version_label(),
"25.0.3",
"无引号形态也要认"
);
assert_eq!(
row("something unexpected 21", Some(21)).version_label(),
"something unexpected 21",
"解析不出来时给原始版本串,不给光秃秃的大版本"
);
assert_eq!(row("", Some(21)).version_label(), "21");
assert_eq!(row("", None).version_label(), "?");
}
#[test]
fn the_summary_carries_version_and_vendor() {
assert_eq!(
row("openjdk version \"21.0.11\" 2025-10-21 LTS", Some(21)).summary(),
"Java 21.0.11 · Microsoft"
);
}
}
@@ -0,0 +1,75 @@
//! 「Java」分组的截图种子(M175,`ULL_JAVA_STATE`):**只给 `--screenshot` 用**。
//!
//! 三档对应任务书要的三张图:未安装 Java 的空态 / 检测到两个 JDK 并选中其一 / 内存与 JVM 参数
//! 的编辑态。编辑态只让**内存**越界(`validate()` 一次只报第一个错,所以图上看到的是「内存超出
//! 允许范围」+ 内存框标红;引号那条另有单测,不靠这张图证明)。
//!
//! 与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` / `ULL_FONT_STATUS` 同一族取证开关:只影响这一次进程,
//! 产品路径不设它。种子里的路径是**写死的示例路径**(真实机器上没有),所以它只在出图通道里出现;
//! 产品路径的真值一律来自 `core::game::java::staged` 的真扫盘。
use super::state::{Context, Layer, Notice, Refusal, Requirement, RuntimeRow, State};
/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用)。
///
/// 三档对应三张图:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与
/// JVM 参数、还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。
#[must_use]
pub fn build(raw: &str) -> Option<State> {
let mut state = State {
storage: true,
scanned: true,
..State::default()
};
match raw.trim() {
"empty" => {
state.memory = "2048".to_string();
state.saved_memory = 2048;
}
"two" => {
state.runtimes = vec![
RuntimeRow {
binary: "C:/Program Files/Eclipse Adoptium/jdk-17.0.13/bin/java.exe"
.to_string(),
version: "openjdk version \"17.0.13\" 2024-10-15".to_string(),
major: Some(17),
vendor: Some("Temurin".to_string()),
source: "system",
},
RuntimeRow {
binary: "C:/Program Files/Microsoft/jdk-21.0.4/bin/java.exe".to_string(),
version: "openjdk version \"21.0.4\" 2024-07-16 LTS".to_string(),
major: Some(21),
vendor: Some("Microsoft".to_string()),
source: "system",
},
];
state.selected = state.runtimes[1].binary.clone();
state.manual = vec!["D:/jdk/zulu-21".to_string()];
state.memory = "4096".to_string();
state.saved_memory = 4096;
state.jvm = "-XX:+UseG1GC".to_string();
state.saved_jvm = "-XX:+UseG1GC".to_string();
state.context = Context {
required: Some(Requirement {
instance: "生存服".to_string(),
version: "1.20.1".to_string(),
minimum: 17,
maximum: None,
}),
layer: Some(Layer::Global),
effective_memory_mb: Some(4096),
};
}
"editing" => {
let mut seeded = build("two")?;
seeded.memory = "128".to_string();
seeded.jvm = "-XX:+UseG1GC -Xss\"512k".to_string();
seeded.dirty = true;
seeded.notice = Some(Notice::Refused(Refusal::MemoryRange));
return Some(seeded);
}
_ => return None,
}
Some(state)
}
@@ -0,0 +1,374 @@
//! 「Java」分组的运行期状态(M175):扫描结论 + 手动名单 + 两个可编辑项。
//!
//! 状态住在 `pages::settings::Groups.java`([`State`]),由 `App` 持有。三条边界:
//!
//! | 东西 | 住哪 | 为什么 |
//! |---|---|---|
//! | 扫描结论 / 进度 | 本结构(由后台线程的快照 1 拍一拍灌进来) | 用户切走再回来不该重扫 |
//! | 手动加的路径名单 | 本结构 + 一个纯文本文件(`store::manual_file`) | 它是「我加过哪些」的记忆,不是启动策略 |
//! | `java.selected_path` / `default_memory_mb` / `extra_args` | **`config/launcher.json`** | 真值只有一份,读回也读它 |
//!
//! ## 为什么内存草稿要记两层(`memory` 文本 + `saved_memory` 数值)
//!
//! 输入框里的是**文本**(用户可能刚敲了半截「819」),而「现在生效多少」是**落盘的真值**。
//! 两者混成一个字段就会重演「显示的就是生效的」这种假象:改到一半的数字不是生效值。
//! 于是草稿只影响「有未保存改动」与「保存按钮能不能点」,生效值一律来自 `saved_*`(落盘后读回的)。
//!
//! ## 版本门槛从哪来
//!
//! [`Context::required`] 由 **app 层**(`app/update/settings/java.rs`)算好塞进来:它要读实例快照
//! (`requirement_for(mc_version)`),而页面渲染器只拿得到 [`super::States`]。
//! 页面自己不去读盘、也不复制版本表。
pub use super::row::RuntimeRow;
use unlucky_i18n::Key;
/// `java.default_memory_mb` 的下限(MB)。
///
/// **必须**等于 `core::setting::keys` 里那个键登记的下限(512):UI 放行一个 schema 会拒的值,
/// 用户点保存只会得到一条「schema 拒绝」的错。一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
pub const MIN_MEMORY_MB: u32 = 512;
/// `java.default_memory_mb` 的上限(MB)。
pub const MAX_MEMORY_MB: u32 = 65536;
/// `java.default_memory_mb` 的出厂默认值(MB):与 schema 的默认值同源,
/// 一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
pub const DEFAULT_MEMORY_MB: u32 = 2048;
/// 扫描进度(`None` = 没在扫)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Progress {
/// 已探测的候选数
pub done: usize,
/// 候选总数
pub total: usize,
}
/// 当前实例对 Java 的版本要求(app 层算好塞进来)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Requirement {
/// 实例名(「为什么选它」那句话里要指名道姓)
pub instance: String,
/// 实例绑的版本 id(`1.20.1`)
pub version: String,
/// 最低主版本
pub minimum: u32,
/// 最高主版本(老版本才有;`None` = 不封顶)
pub maximum: Option<u32>,
}
impl Requirement {
/// 某个大版本满不满足。
#[must_use]
pub fn satisfied_by(&self, major: u32) -> bool {
major >= self.minimum && self.maximum.is_none_or(|max| major <= max)
}
}
/// 生效内存来自哪一层。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Layer {
/// 全局 `java.default_memory_mb`
Global,
/// 某个实例的 `launch.memoryMb` 覆盖了全局
Instance {
/// 实例名
instance: String,
/// 实例层的值
mb: u32,
},
}
/// 上下文(app 层每次 hydrate 重算)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Context {
/// 当前实例的版本要求(没有实例 ⇒ `None`)
pub required: Option<Requirement>,
/// 当前生效内存来自哪一层
pub layer: Option<Layer>,
/// 当前生效内存(三级回落之后的数值)
pub effective_memory_mb: Option<u32>,
}
/// 保存 / 操作的回执。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Notice {
/// 已落盘并读回
Saved,
/// 本地校验拒绝(没写盘)
Refused(Refusal),
/// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key,不拼字符串)
Unwritable(Unwritable),
/// 写盘失败(schema / 磁盘):core 的 `Display` 是中文,不能直出给英文界面,
/// 所以这里只带一个页面自己的 key;原始句子进 stderr 日志。
WriteFailed,
/// 没有可写文件(内存态运行)
NoStorage,
/// 默认 JDK 已写进 `java.selected_path`
DefaultSet(String),
/// 手动路径已加入名单
Added(String),
}
/// 设置文件层面拒绝的三档(`SettingStore::save` 的 `Skipped` 三因)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unwritable {
/// 没有任何脏键(我们以为改了、其实没有)
NotDirty,
/// 文件只读(被更新版本的启动器写过 / 被标记不可信)
ReadOnly,
/// 文件不可信(加载时就降级成只读)
Unreadable,
}
impl Unwritable {
/// 文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::NotDirty => Key::SettingsJavaSkipNotDirty,
Self::ReadOnly => Key::SettingsJavaSkipReadOnly,
Self::Unreadable => Key::SettingsJavaSkipUnreadable,
}
}
}
/// 本地校验的拒绝原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
/// 内存草稿为空
MemoryEmpty,
/// 内存草稿不是整数
MemoryNotNumber,
/// 内存越界
MemoryRange,
/// JVM 参数里的引号没配成对
JvmQuotes,
/// 手动路径为空
AddEmpty,
/// 手动路径在盘上找不到
AddMissing,
/// 有这个东西,但不是 java 可执行文件
AddNotJava,
/// 跑不出 `-version`
AddNoVersion,
/// 路径太长(粘了一整段日志)
AddTooLong,
/// 名单文件写不进去(磁盘 / 权限)
AddWriteFailed,
/// 已经加过了
AddDuplicate,
}
impl Refusal {
/// 文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::MemoryEmpty => Key::SettingsJavaReasonMemoryEmpty,
Self::MemoryNotNumber => Key::SettingsJavaReasonMemoryNotNumber,
Self::MemoryRange => Key::SettingsJavaReasonMemoryRange,
Self::JvmQuotes => Key::SettingsJavaReasonJvmQuotes,
Self::AddEmpty => Key::SettingsJavaReasonAddEmpty,
Self::AddMissing => Key::SettingsJavaReasonAddMissing,
Self::AddNotJava => Key::SettingsJavaReasonAddNotJava,
Self::AddNoVersion => Key::SettingsJavaReasonAddNoVersion,
Self::AddTooLong => Key::SettingsJavaReasonAddTooLong,
Self::AddWriteFailed => Key::SettingsJavaReasonAddWriteFailed,
Self::AddDuplicate => Key::SettingsJavaReasonAddDuplicate,
}
}
}
/// 「Java」分组的运行期状态。
#[derive(Debug)]
pub struct State {
/// 扫到的运行时(顺序 = 大版本升序,探测不到主版本的排最后)
pub runtimes: Vec<RuntimeRow>,
/// 至少完整扫过一次(区分「未检测到」与「还没扫」)
pub scanned: bool,
/// 扫描进度
pub scanning: Option<Progress>,
/// 上一次扫描被用户取消(界面上要说一句,否则「列表没变」会被当成没扫)
pub cancelled: bool,
/// 扫描状态槽(快照 + 取消位;线程与界面共享同一份)
pub scan: super::store::ScanSlot,
/// 用户手动加的路径(原样,持久化在 `manual_file`)
pub manual: Vec<String>,
/// `java.selected_path` 的真值(空 = 没指定,启动时自动挑)
pub selected: String,
/// 内存草稿(MB 文本)
pub memory: String,
/// 落盘的内存真值(MB)
pub saved_memory: u32,
/// JVM 参数草稿
pub jvm: String,
/// 落盘的 JVM 参数真值
pub saved_jvm: String,
/// 手动路径输入框
pub add_draft: String,
/// 有未保存的改动
pub dirty: bool,
/// 这一次运行有没有可写的设置文件
pub storage: bool,
/// 上一次操作的回执
pub notice: Option<Notice>,
/// 当前实例的上下文(版本门槛 / 生效内存层级)
pub context: Context,
}
impl Default for State {
fn default() -> Self {
Self {
runtimes: Vec::new(),
scanned: false,
scanning: None,
cancelled: false,
scan: super::store::ScanSlot::default(),
manual: Vec::new(),
selected: String::new(),
memory: String::new(),
saved_memory: DEFAULT_MEMORY_MB,
jvm: String::new(),
saved_jvm: String::new(),
add_draft: String::new(),
dirty: false,
storage: false,
notice: None,
context: Context::default(),
}
}
}
/// 从 `launcher.json` 读回来的三键真值。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Snapshot {
/// `java.selected_path`
pub selected: String,
/// `java.default_memory_mb`
pub memory_mb: u32,
/// `java.extra_args`
pub jvm: String,
}
impl State {
/// 用真值刷新(**脏草稿不覆盖**,否则外部一改文件就冲掉用户正在输入的内容)。
///
/// 返回是否改了「生效值」那一半(调用方据此决定要不要重建视图模型)。
pub fn hydrate(&mut self, snapshot: Snapshot) -> bool {
self.storage = true;
let Snapshot {
selected,
memory_mb,
jvm,
} = snapshot;
let changed =
self.selected != selected || self.saved_memory != memory_mb || self.saved_jvm != jvm;
self.selected = selected;
self.saved_memory = memory_mb;
if !self.dirty {
self.memory = memory_mb.to_string();
self.jvm = jvm.clone();
}
self.saved_jvm = jvm;
changed
}
/// 灌一次扫描结论(后台线程的快照)。返回是否变了。
pub fn apply_scan(&mut self, report: &super::store::ScanReport) -> bool {
let scanning = report.running.then_some(Progress {
done: report.done,
total: report.total,
});
let changed = self.scanning != scanning
|| self.cancelled != report.cancelled
|| (report.finished && !self.scanned)
|| (report.finished && self.runtimes != report.runtimes);
self.scanning = scanning;
self.cancelled = report.cancelled;
if report.finished {
self.scanned = true;
self.runtimes = report.runtimes.clone();
}
changed
}
/// 某个大版本够不够当前实例用。
#[must_use]
pub fn satisfied(&self, major: Option<u32>) -> Option<bool> {
let (Some(required), Some(major)) = (self.context.required.as_ref(), major) else {
return None;
};
Some(required.satisfied_by(major))
}
/// 保存前的本地校验(过不了就不写盘)。
///
/// # Errors
/// 内存不是整数 / 越界,或 JVM 参数的引号没配成对时返回原因。
pub fn validate(&self) -> Result<u32, Refusal> {
let text = self.memory.trim();
if text.is_empty() {
return Err(Refusal::MemoryEmpty);
}
let Ok(mb) = text.parse::<u32>() else {
return Err(Refusal::MemoryNotNumber);
};
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&mb) {
return Err(Refusal::MemoryRange);
}
if unbalanced_quotes(&self.jvm) {
return Err(Refusal::JvmQuotes);
}
Ok(mb)
}
/// 界面上「现在生效多少」的那句话用哪一层(没有上下文时按全局)。
#[must_use]
pub fn layer(&self) -> Layer {
self.context.layer.clone().unwrap_or(Layer::Global)
}
/// 现在能不能点保存。
#[must_use]
pub fn can_save(&self) -> bool {
self.storage && self.dirty
}
/// 当前默认运行时那一行(`selected` 指向的)。
#[must_use]
pub fn default_row(&self) -> Option<&RuntimeRow> {
self.runtimes
.iter()
.find(|row| same_path(&row.binary, &self.selected))
}
/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用;三档的实现在 `java/seed.rs`)。
///
/// 三档:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与 JVM 参数、
/// 还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。
#[must_use]
pub fn screenshot_seed(raw: &str) -> Option<Self> {
super::seed::build(raw)
}
}
/// 两条路径是不是同一个(`/` 与 `\` 视为同一分隔符;Windows 折大小写)。
#[must_use]
pub fn same_path(left: &str, right: &str) -> bool {
let key = |text: &str| {
let text = text.replace('\\', "/");
if cfg!(windows) {
text.to_ascii_lowercase()
} else {
text
}
};
!left.trim().is_empty() && key(left.trim()) == key(right.trim())
}
/// 引号(`"` 与 `'`)数量是不是奇数——奇数就意味着用户漏了一半。
#[must_use]
pub fn unbalanced_quotes(text: &str) -> bool {
text.matches('"').count() % 2 == 1 || text.matches('\'').count() % 2 == 1
}
@@ -0,0 +1,393 @@
//! 「Java」分组的真读写(M175):设置三键 + 手动名单 + 后台扫描。
//!
//! ## 写盘走 `SettingStore`,与 `ai/store.rs` 同一条通路
//!
//! `java.selected_path` / `java.default_memory_mb` / `java.extra_args` 都是
//! `config/launcher.json` 里的 schema 键(`core::setting::keys` 已登记,本 mission 不动 schema)。
//! 于是这里只做「读一份 → 改几个键 → 存 → **再从磁盘读回来**」:`save()` 有 `Skipped` 一档
//! (只读 / 文件被更新版本写过 / 没有脏键),把它当成功就会出现「界面说已保存、重启回旧值」。
//! 失败一律走 [`WriteError`]:**要么是页面自己的拒绝原因(i18n key),要么是 core 给的原始句子**,
//! 不再把 Rust 枚举名(`"MemoryRange"`)当人话往上抛(M175 r1 复审 P2-2)。
//!
//! ## 扫描为什么是「进程级快照 + 后台线程」,不是 `Task::perform`
//!
//! 与 `fonts::install` 同一条理由(那里记了完整推导):本 crate 的执行器是 `thread-pool`,
//! 而扫描要的是**进度(3/7)+ 取消 + 不卡 UI**。快照方案的代价是一把短锁 + 1 拍一次比较,
//! 好处是窗口关掉之后不会往死掉的 channel 里写。界面的刷新节奏见 `app/update/clock.rs`
//! 与 `app/subscription.rs`(扫描期间按帧跑,扫描结束回到 1 Hz)。
//!
//! ### 三条与「取消」有关的硬约束(M175 r1 复审 P2-5)
//!
//! 1. **每个候选都有超时**:卡住的 `java -version` 由 `core::game::java::manual` 的
//! `run_with_timeout` kill 掉,扫描线程不会永远不结束;
//! 2. **取消打断的是「正在跑的那一个」**:取消位一路传进探测函数,不必等它自己结束;
//! 3. **同时只允许一个扫描线程**:[`ScanReport::running`] 的含义就是「线程还活着」,
//! 取消**不**翻这个位(只置 `cancelled`),于是取消到线程真退出之间点「立即扫描」
//! 不会起第二个线程——界面上那一小段显示的是「正在取消…」。
//!
//! ## 手动名单为什么是纯文本文件
//!
//! 它记的是「我手动加过哪些路径」,不是启动策略(真正决定用哪个 Java 的是 `java.selected_path`)。
//! 放进 `launcher.json` 要新登记一个 schema 键并推 minor(会让旧版本整份文件变只读),
//! 代价远大于收益;而 `core::game::java::manual` 的读写是纯文本、一行一个,坏了顶多丢名单。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use unlucky_core::game::java::{discover, manual, staged, JavaRuntime};
use unlucky_core::platform::paths::{Platform, ProcessEnv};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
use super::state::{Refusal, RuntimeRow, Snapshot, State, Unwritable};
/// 扫描结果快照(后台线程写、界面读)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScanReport {
/// 扫描线程**还活着**(取消之后到它真退出之前仍是 `true`——这一段界面上叫「正在取消…」)
pub running: bool,
/// 已探测
pub done: usize,
/// 候选总数(`0` = 候选表还在收集,界面不显示 `0/0`)
pub total: usize,
/// 至少完整扫完过一次
pub finished: bool,
/// 用户按过取消(线程还活着时是 `true`)
pub cancelled: bool,
/// 扫描结论(`finished` 且没被取消时有效)
pub runtimes: Vec<RuntimeRow>,
}
/// 一次扫描的作业(生产实现是 [`run_staged`];测试注入可控的假作业)。
pub type Job =
fn(&[staged::Candidate], &dyn Fn() -> bool, &mut dyn FnMut(usize, usize)) -> Vec<RuntimeRow>;
/// 一次扫描的**状态槽**:快照 + 取消位 + 「线程还活着」。
///
/// 为什么不是进程级 static(M175 r1 复审 P2-5 的附带项):全局状态有两个坏处——
/// ①候选收集要在 UI 线程上抢同一把锁;②单测之间互相污染(一个用例起的扫描会让另一个用例
/// 看到「变了」)。挂在 `State` 上之后,每个 `App` / 每个用例都有一份自己的槽。
///
/// 内部是 `Arc`:`State` 会被整体替换(hydrate),而扫描线程要活得比某一次 `State` 赋值更久。
#[derive(Debug, Clone, Default)]
pub struct ScanSlot {
inner: std::sync::Arc<ScanInner>,
}
#[derive(Debug, Default)]
struct ScanInner {
report: Mutex<ScanReport>,
cancel: AtomicBool,
}
impl ScanSlot {
/// 当前快照(界面每拍读一次;锁只在这几行里持有)。
#[must_use]
pub fn report(&self) -> ScanReport {
self.inner
.report
.lock()
.map(|report| report.clone())
.unwrap_or_default()
}
/// 取消正在跑的扫描:置取消位 + 标 `cancelled`,**不**动 `running`。
///
/// `running` 由扫描线程自己翻——它是「线程还活着」的唯一真相。取消若也翻它,`start()` 的
/// 「已在扫」判据就失效,用户能在旧线程还卡着的时候起第二个(复审 P2-5 的第三个问题)。
pub fn cancel(&self) {
self.inner.cancel.store(true, Ordering::SeqCst);
if let Ok(mut report) = self.inner.report.lock() {
if report.running {
report.cancelled = true;
}
}
}
/// 发起一次扫描(已在扫 ⇒ 什么都不做,返回 `false`)。
pub fn start(&self, roots: Vec<PathBuf>, manual: Vec<PathBuf>, system: bool) -> bool {
self.start_with(roots, manual, system, run_staged)
}
/// 同 [`ScanSlot::start`],但扫描作业可注入(测试用:一个可控的、会一直卡住的作业)。
pub fn start_with(
&self,
roots: Vec<PathBuf>,
manual: Vec<PathBuf>,
system: bool,
job: Job,
) -> bool {
{
let Ok(mut report) = self.inner.report.lock() else {
return false;
};
if report.running {
return false;
}
// 「占位」与「线程起来了」在同一把锁里完成:两个调用不可能同时通过上面那道判据。
*report = ScanReport {
running: true,
..ScanReport::default()
};
self.inner.cancel.store(false, Ordering::SeqCst);
}
let slot = self.clone();
std::thread::spawn(move || {
// 候选收集(注册表 / 常见目录)放在线程里:它是首屏那一拍最贵的一段,
// 放在 UI 线程上会拖慢开窗(复审 P2-5 的附带项)。
let candidates = staged::candidates(&roots, &manual, system);
if let Ok(mut report) = slot.inner.report.lock() {
report.total = candidates.len();
}
let cancelled = || slot.inner.cancel.load(Ordering::SeqCst);
let mut progress = |done: usize, _total: usize| {
if let Ok(mut report) = slot.inner.report.lock() {
report.done = done;
}
};
let runtimes = job(&candidates, &cancelled, &mut progress);
if let Ok(mut report) = slot.inner.report.lock() {
report.running = false;
report.done = candidates.len();
if !report.cancelled {
report.finished = true;
report.runtimes = runtimes;
}
}
});
true
}
}
/// 生产扫描作业:真扫盘 + 逐个探测(超时与取消都在 `staged::scan` 里)。
fn run_staged(
candidates: &[staged::Candidate],
cancelled: &dyn Fn() -> bool,
progress: &mut dyn FnMut(usize, usize),
) -> Vec<RuntimeRow> {
let found = staged::scan(
candidates,
|done, total| {
progress(done, total);
true
},
cancelled,
);
rows(&found, candidates)
}
/// `JavaRuntime` → 页面行(来源从候选表里按路径回查:`JavaRuntime.origin` 分不出「手动」)。
fn rows(found: &[JavaRuntime], candidates: &[staged::Candidate]) -> Vec<RuntimeRow> {
found
.iter()
.map(|runtime| RuntimeRow {
binary: runtime.binary.display().to_string(),
version: runtime.version.clone().unwrap_or_default(),
major: runtime.major,
vendor: runtime.vendor.clone(),
source: candidates
.iter()
.find(|candidate| candidate.binary == runtime.binary)
.map_or("system", |candidate| candidate.source.id()),
})
.collect()
}
/// 该不该**自动**发起一次扫描(首屏 / 配置文件刚被外部改过时)。
///
/// 四条都要成立:①不是截图通道(D30:同一份代码出同一张图,截图不许读本机装了什么);
/// ②没有在扫;③**没被取消过**(一次取消 = 本进程不再自动扫:被取消的扫描 `finished` 仍是
/// `false`,而 `launcher.json` 每改一次(存内存、改主题、改语言、存 AI 配置)心跳就会走一次
/// `hydrate_java_settings`,不看这一条就会把用户的取消无声撤销);④还没扫完过(一次就够——
/// 用户手动点「立即扫描」走的是 `start`,不看这里)。
///
/// 单独抽出来是因为它是「用户实测的头一条抱怨」(设置页从来自动检测本机 Java)的开关:
/// 判据写在这里,`tests/cmdline.rs` 逐档断言,改坏立刻红。
#[must_use]
pub fn auto_scan_due(report: &ScanReport, screenshot: bool) -> bool {
!screenshot && !report.running && !report.cancelled && !report.finished
}
/// 托管安装根(`<launcher home>/data/java-managed` + `java.installRoot` + 标准落点)。
///
/// 从**我们正在写的那份 `launcher.json`** 的上一级推 launcher home:这比重新解析一次数据根模式
/// 更可信(三模式解析已经在 `SettingsBridge::open` 里做过一遍了)。
#[must_use]
pub fn managed_roots(config: &Path) -> Vec<PathBuf> {
let Some(home) = config.parent().and_then(Path::parent) else {
return Vec::new();
};
let layout = unlucky_core::platform::layout::LauncherLayout::new(home);
let override_root = SettingStore::load(config, launcher_schema())
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
unlucky_core::game::java::location::scan_roots(
unlucky_core::platform::data_dir::DataMode::Portable,
&layout,
override_root.as_deref(),
Platform::current(),
&ProcessEnv,
)
}
/// 手动名单文件(与 `launcher.json` 同一个 `config/`)。
#[must_use]
pub fn manual_file(config: &Path) -> PathBuf {
config.parent().map_or_else(
|| PathBuf::from("java-extra-paths.txt"),
|dir| dir.join("java-extra-paths.txt"),
)
}
/// 读三键真值。
#[must_use]
pub fn load(config: &Path) -> Snapshot {
snapshot(SettingStore::load(config, launcher_schema()).doc())
}
/// 写盘失败的三种来源(都是「没写进去」,但对用户要说的不是同一句话)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WriteError {
/// 页面自己的校验拒绝(i18n key)
Refused(Refusal),
/// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key)
Unwritable(Unwritable),
/// schema / 磁盘给的原始句子(core 的文案,不属于本页)
Schema(String),
}
/// 保存内存 + JVM 参数,返回**落盘后读回来**的真值。
///
/// # Errors
/// 本地校验不过([`WriteError::Refused`])、文件只读/不可信([`WriteError::Unwritable`])
/// 或 schema 拒绝([`WriteError::Schema`])时返回原因。
pub fn save(config: &Path, state: &State) -> Result<Snapshot, WriteError> {
let mb = state.validate().map_err(WriteError::Refused)?;
let mut store = SettingStore::load(config, launcher_schema());
store
.set("java.default_memory_mb", i64::from(mb).into())
.and_then(|()| store.set("java.extra_args", state.jvm.trim().to_string().into()))
.map_err(|error| WriteError::Schema(format!("{error}")))?;
finish(store, config)
}
/// 写 `java.selected_path`(选定默认 JDK),返回落盘后读回来的真值。
///
/// # Errors
/// 文件只读/不可信或 schema 拒绝时返回原因。
pub fn set_selected(config: &Path, binary: &str) -> Result<Snapshot, WriteError> {
let mut store = SettingStore::load(config, launcher_schema());
store
.set("java.selected_path", binary.to_string().into())
.map_err(|error| WriteError::Schema(format!("{error}")))?;
finish(store, config)
}
/// 落盘 + 判定 + 读回(`save` / `set_selected` 共用:只认 `Saved` 一档)。
fn finish(mut store: SettingStore, config: &Path) -> Result<Snapshot, WriteError> {
match store.save() {
Ok(SaveOutcome::Saved(_)) => Ok(load(config)),
Ok(SaveOutcome::Skipped(reason)) => Err(WriteError::Unwritable(unwritable(reason))),
Err(error) => Err(WriteError::Schema(format!("{error}"))),
}
}
/// `SkipReason` → 页面原因(**不**再用中文字符串:那是 r1 复审 P2-2 的第一条)。
fn unwritable(reason: unlucky_core::setting::store::SkipReason) -> Unwritable {
use unlucky_core::setting::store::SkipReason;
match reason {
SkipReason::NotDirty => Unwritable::NotDirty,
SkipReason::ReadOnly => Unwritable::ReadOnly,
SkipReason::Unreadable => Unwritable::Unreadable,
}
}
/// 文档 → 快照。
fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot {
Snapshot {
selected: doc
.effective_str("java.selected_path")
.unwrap_or_default()
.trim()
.to_string(),
memory_mb: doc
.effective_i64("java.default_memory_mb")
.and_then(|value| u32::try_from(value).ok())
.unwrap_or(super::state::DEFAULT_MEMORY_MB),
jvm: doc
.effective_str("java.extra_args")
.unwrap_or_default()
.to_string(),
}
}
/// 读手动名单。
#[must_use]
pub fn load_manual(config: &Path) -> Vec<String> {
manual::load_paths(&manual_file(config))
.iter()
.map(|path| path.display().to_string())
.collect()
}
/// 手动加一个路径:严格校验(真跑 `java -version`)→ 进名单 → 落盘。
///
/// 拒绝时**什么都不写**(名单不变、设置不变)——这是「非法路径不污染设置」那条守卫的落点。
///
/// # Errors
/// 校验不过(见 [`Refusal`])或名单写盘失败时返回原因。
pub fn add_manual(config: &Path, input: &str) -> Result<(String, RuntimeRow), Refusal> {
add_manual_with(config, input, &manual::probe_manual)
}
/// 同 [`add_manual`],但探测函数可注入(测试用:不依赖本机装没装 JDK)。
///
/// # Errors
/// 探测被拒或名单写盘失败时返回原因。
pub fn add_manual_with(
config: &Path,
input: &str,
probe: &dyn Fn(&str) -> Result<JavaRuntime, manual::ManualFailure>,
) -> Result<(String, RuntimeRow), Refusal> {
let runtime = probe(input.trim()).map_err(failure_refusal)?;
let binary = runtime.binary.display().to_string();
let mut paths = manual::load_paths(&manual_file(config));
let key = discover::path_key(&runtime.binary);
if paths.iter().any(|known| discover::path_key(known) == key) {
return Err(Refusal::AddDuplicate);
}
paths.push(runtime.binary.clone());
manual::save_paths(&manual_file(config), &paths).map_err(|_| Refusal::AddWriteFailed)?;
Ok((
binary.clone(),
RuntimeRow {
binary,
version: runtime.version.clone().unwrap_or_default(),
major: runtime.major,
vendor: runtime.vendor.clone(),
source: "manual",
},
))
}
/// `core` 的拒绝原因 → 页面原因。
fn failure_refusal(failure: manual::ManualFailure) -> Refusal {
match failure {
manual::ManualFailure::Empty => Refusal::AddEmpty,
manual::ManualFailure::TooLong => Refusal::AddTooLong,
manual::ManualFailure::Missing => Refusal::AddMissing,
manual::ManualFailure::NotJava => Refusal::AddNotJava,
manual::ManualFailure::NoVersion => Refusal::AddNoVersion,
}
}
/// 设置文件路径(数据根解析不出来时没有)。
#[must_use]
pub fn config_of(bridge: &unlucky_presenter::settings_bridge::SettingsBridge) -> Option<PathBuf> {
bridge.path().map(Path::to_path_buf)
}
@@ -0,0 +1,242 @@
//! 「Java」分组的真数据源断言(M175):扫描 / 手动路径 / 内存 / JVM 参数 / 真命令行。
//!
//! 每一条都**能变红**(报告的「变异证明」逐条给了改哪一行会红)。四条守卫:
//!
//! | 守卫 | 断言 | 改回什么会红 |
//! |---|---|---|
//! | 列表来自真扫盘 | [`the_runtime_list_comes_from_the_disk`] | 把行换成写死的字符串 / 不读候选表 |
//! | 非法路径不污染设置 | [`an_invalid_manual_path_is_refused_and_writes_nothing`] | 删掉 `manual::probe_manual` 的校验 |
//! | 内存越界被拒 | [`the_memory_bounds_are_enforced_before_writing`] | 删掉范围判断 / 允许非数字 |
//! | 内存真进命令行 | [`the_saved_memory_reaches_the_real_command_line`] | 把 `-Xmx` 的来源改回常量 |
//!
//! 前三条不依赖本机装没装 JDK(假的 `bin/java` 空文件就够:它扫得到、但答不出版本),
//! 第四条(`tests/cmdline.rs`)走 core 的**真组装链**(`launch::args_table::assemble`)——
//! 不是读配置回显。落盘 / 读回 / 版本门槛 / 截图种子也在那个文件里(同一族「真值」断言)。
#[path = "tests/cmdline.rs"]
mod cmdline;
use std::path::{Path, PathBuf};
use super::state::{Refusal, RuntimeRow, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
use super::store;
/// 每个用例一个临时数据根(`<dir>/config/launcher.json` 的形状与真实数据根一致)。
pub(super) fn root(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-java-page-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config");
dir
}
pub(super) fn config(dir: &Path) -> PathBuf {
dir.join("config").join("launcher.json")
}
/// 造一个「看起来像 JDK」的目录:`bin/java` 是空文件(扫得到、但 `-version` 答不出)。
pub(super) fn fake_jdk(dir: &Path, name: &str) -> PathBuf {
let jdk = dir.join(name);
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
std::fs::write(jdk.join("bin").join(java_bin()), b"").expect("写 java");
jdk
}
pub(super) fn java_bin() -> &'static str {
if cfg!(windows) {
"java.exe"
} else {
"java"
}
}
// --------------------------------------------------------------- 守卫①:列表来自真扫盘
/// 扫描列表必须来自**盘上真有的目录**:造两个 → 两项;删一个 → 一项。
///
/// 变异证明:把 `store::start` 里的候选表换成一个写死的两行常量,删目录之后仍是两项 ⇒ 红。
#[test]
fn the_runtime_list_comes_from_the_disk() {
let dir = root("scan");
let one = fake_jdk(&dir, "jdk-a");
let two = fake_jdk(&dir, "jdk-b");
let found =
unlucky_core::game::java::staged::scan_roots(&[], &[one.clone(), two.clone()], false);
assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}");
std::fs::remove_dir_all(&one).expect("删 jdk-a");
let after = unlucky_core::game::java::staged::scan_roots(&[], &[one, two], false);
assert_eq!(after.len(), 1, "删掉一个之后必须少一项:{after:?}");
std::fs::remove_dir_all(&dir).ok();
}
/// 扫描结论灌进页面状态之后,**行数与路径**都来自那次扫描(不是常量)。
#[test]
fn applying_a_scan_report_replaces_every_row() {
let mut state = State::default();
let report = store::ScanReport {
running: false,
done: 2,
total: 2,
finished: true,
cancelled: false,
runtimes: vec![
RuntimeRow {
binary: "C:/jdk-a/bin/java.exe".to_string(),
version: String::new(),
major: None,
vendor: None,
source: "manual",
},
RuntimeRow {
binary: "C:/jdk-b/bin/java.exe".to_string(),
version: String::new(),
major: Some(21),
vendor: Some("Temurin".to_string()),
source: "system",
},
],
};
assert!(state.apply_scan(&report), "第一次灌结论必须报告「变了」");
assert_eq!(state.runtimes.len(), 2);
assert!(state.scanned, "扫完之后「未检测到」与「还没扫」必须能分开");
assert_eq!(state.runtimes[1].summary(), "Java 21 · Temurin");
}
// --------------------------------------------------------------- 守卫②:非法路径不污染设置
/// 非法路径被拒、**设置与名单一个字节都不写**。
///
/// 变异证明:删掉 `store::add_manual` 里的 `manual::probe_manual`(改成只看 `is_dir()`),
/// 「不存在的路径」就会被加进名单 ⇒ 红。
#[test]
fn an_invalid_manual_path_is_refused_and_writes_nothing() {
let dir = root("manual");
let file = config(&dir);
assert_eq!(
store::add_manual(&file, "").err(),
Some(Refusal::AddEmpty),
"空输入要被拒"
);
assert_eq!(
store::add_manual(&file, "Z:/definitely/not/here").err(),
Some(Refusal::AddMissing),
"不存在的路径要被拒"
);
let empty = dir.join("empty-jdk");
std::fs::create_dir_all(&empty).expect("空目录");
assert_eq!(
store::add_manual(&file, &empty.display().to_string()).err(),
Some(Refusal::AddNotJava),
"目录里没有 bin/java 要被拒"
);
let broken = fake_jdk(&dir, "broken-jdk");
assert_eq!(
store::add_manual(&file, &broken.display().to_string()).err(),
Some(Refusal::AddNoVersion),
"跑不出 java -version 要被拒"
);
assert!(
store::load_manual(&file).is_empty(),
"被拒的路径不许进名单:{:?}",
store::load_manual(&file)
);
let snapshot = store::load(&file);
assert!(
snapshot.selected.is_empty(),
"被拒的路径不许污染 java.selected_path:{snapshot:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 守卫③:内存越界被拒
/// 内存必须落在 schema 登记的区间里,非数字也要拒,而且**不写盘**。
///
/// 变异证明:删掉 `State::validate` 里的范围判断(只留 `parse`),128 与 999999 会落盘 ⇒ 红;
/// 把 `parse::<u32>` 换成 `unwrap_or(2048)`,`"abc"` 不再报错 ⇒ 红。
#[test]
fn the_memory_bounds_are_enforced_before_writing() {
let mut state = State {
storage: true,
..State::default()
};
state.memory = (MIN_MEMORY_MB - 1).to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::MemoryRange),
"低于下限"
);
state.memory = (MAX_MEMORY_MB + 1).to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::MemoryRange),
"高于上限"
);
state.memory = "abc".to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::MemoryNotNumber),
"非数字"
);
state.memory = " ".to_string();
assert_eq!(state.validate().err(), Some(Refusal::MemoryEmpty), "空");
state.memory = "4096".to_string();
assert!(state.validate().is_ok());
state.jvm = "-XX:+UseG1GC".to_string();
assert!(state.validate().is_ok());
state.jvm = "-Xss\"512k".to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::JvmQuotes),
"引号没配对"
);
// 真的不写盘:一次越界保存之后,文件里还是出厂默认值。
let dir = root("memory");
let file = config(&dir);
let state = State {
storage: true,
memory: "128".to_string(),
..State::default()
};
assert!(store::save(&file, &state).is_err(), "越界必须写不进去");
assert_eq!(
store::load(&file).memory_mb,
super::state::DEFAULT_MEMORY_MB,
"被拒的保存不许落盘"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 两个数字常量必须与 schema 登记的一致(否则 UI 放行的值会被 schema 拒)。
///
/// 变异证明:把 `MIN_MEMORY_MB` 改成 256(core 的 `launch_overrides` 那一档),这条立刻红。
#[test]
fn the_memory_bounds_mirror_the_schema() {
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::ValueKind;
let schema = launcher_schema();
let kind = schema
.spec("java.default_memory_mb")
.map(|spec| &spec.kind)
.expect("schema 里必须有 java.default_memory_mb");
let ValueKind::Int { min, max } = kind else {
panic!("java.default_memory_mb 应该是 Int:{kind:?}");
};
assert_eq!(*min, i64::from(MIN_MEMORY_MB));
assert_eq!(*max, i64::from(MAX_MEMORY_MB));
}
#[path = "tests/runtime.rs"]
mod runtime;
@@ -0,0 +1,262 @@
//! `tests.rs` 的后半:真命令行 / 落盘读回 / 版本门槛 / 截图种子。
//!
//! 拆出来的理由:`tests.rs` 加上守卫之后 434 行,越过 400 行硬线;这一半是「真值走完整条生产链」
//! 的一族(`launcher.json` → 组装链 / 重启后读回),与前半的「输入校验」不是一件事。
//! 公共的临时目录与假 JDK 构造在父模块(`super::root` / `super::fake_jdk`)。
use std::path::Path;
use super::super::state::{Layer, Notice, Requirement, RuntimeRow, State};
use super::super::store;
use super::{config, fake_jdk, root};
// --------------------------------------------------------------- 守卫④:内存真进命令行
/// 写进 `java.default_memory_mb` 的值必须出现在**真组装出来的 JVM 参数**里。
///
/// 走的是生产代码那条链:`launcher.json` → `GlobalLaunchDefaults` → `launch_overrides::resolve`
/// → `launch::overrides::apply` → `launch::args_table::assemble`(`ull launch --dry-run` 用的是同一条)。
/// 不是读配置回显:断言的是 `-Xmx<新值>m` 这个 argv 元素。
///
/// 变异证明:把 `store::save` 里写 `java.default_memory_mb` 的那一行删掉(或写死 2048),
/// 这条立刻红(argv 里还是 `-Xmx2048m`)。
#[test]
fn the_saved_memory_reaches_the_real_command_line() {
let dir = root("cmdline");
let file = config(&dir);
let state = State {
storage: true,
memory: "6144".to_string(),
jvm: "-Dfile.encoding=UTF-8".to_string(),
..State::default()
};
assert!(state.validate().is_ok());
let saved = store::save(&file, &state).expect("保存应成功");
assert_eq!(saved.memory_mb, 6144, "落盘后读回的值");
assert_eq!(saved.jvm, "-Dfile.encoding=UTF-8");
let args = real_argv(&file);
assert!(
args.iter().any(|arg| arg == "-Xmx6144m"),
"内存字段必须长成 -Xmx6144m:{args:?}"
);
assert!(
args.iter().any(|arg| arg == "-Dfile.encoding=UTF-8"),
"java.extra_args 必须进命令行:{args:?}"
);
assert!(
!args.iter().any(|arg| arg == "-Xmx2048m"),
"出厂默认值不许还在里面:{args:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 用生产链组装一次命令行(`launch.json` 之外什么都不读)。
fn real_argv(config: &Path) -> Vec<String> {
use std::path::PathBuf;
use unlucky_core::game::launch_overrides::{resolve, GlobalLaunchDefaults};
use unlucky_core::launch::args_table::assemble;
use unlucky_core::launch::overrides;
use unlucky_core::launch::platform::{OsFamily, PlatformCtx};
use unlucky_core::launch::request::{
InstanceInfo, JavaSelection, LaunchDirs, ManifestArgs, MemoryOptions,
};
use unlucky_core::launch::request::{
LaunchRequest, LauncherInfo, Log4jOptions, OptimizationOptions,
};
let snapshot = store::load(config);
let resolved = resolve(
None,
&GlobalLaunchDefaults {
jvm_args: Some(snapshot.jvm.clone()),
memory_mb: Some(snapshot.memory_mb),
..GlobalLaunchDefaults::default()
},
);
let mut request = LaunchRequest {
instance: InstanceInfo {
id: "inst-1".to_string(),
name: "1.20.1".to_string(),
version: unlucky_core::launch::game_version::GameVersion::parse("1.20.1"),
version_type: "release".to_string(),
},
launcher: LauncherInfo {
name: "UnluckyLauncher".to_string(),
version: "0.2.3".to_string(),
},
java: JavaSelection::new("C:/java/bin/java.exe", 21, 64),
memory: MemoryOptions {
min_mb: None,
max_mb: None,
},
manifest: ManifestArgs {
main_class: Some("net.minecraft.client.main.Main".to_string()),
..ManifestArgs::default()
},
dirs: LaunchDirs {
game_dir: PathBuf::from("C:/mc"),
primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"),
..LaunchDirs::default()
},
classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")],
log4j: Log4jOptions {
enabled: false,
..Log4jOptions::default()
},
optimization: OptimizationOptions {
enabled: false,
physical_memory_mb: None,
},
language: "zh-CN".to_string(),
..LaunchRequest::default()
};
overrides::apply(&mut request, &resolved);
assemble(&request, &PlatformCtx::new(OsFamily::Windows, "x86_64"))
.expect("组装应成功")
.args
}
// --------------------------------------------------------------- 落盘 / 读回 / 门槛
/// 三键都能落盘、能在「重启后」读回来(新的 `State` + `hydrate` 就是重启的等价物)。
#[test]
fn the_settings_survive_a_restart() {
let dir = root("restart");
let file = config(&dir);
let state = State {
storage: true,
memory: "8192".to_string(),
jvm: "-XX:+UseG1GC".to_string(),
..State::default()
};
store::save(&file, &state).expect("保存");
let selected = store::set_selected(&file, "C:/jdk-17/bin/java.exe").expect("选默认");
// 「重启」:全新的 State 从盘上灌一次。
let mut reborn = State::default();
assert!(reborn.hydrate(store::load(&file)));
assert_eq!(reborn.memory, "8192");
assert_eq!(reborn.saved_memory, 8192);
assert_eq!(reborn.jvm, "-XX:+UseG1GC");
assert_eq!(reborn.selected, "C:/jdk-17/bin/java.exe");
assert_eq!(selected.selected, "C:/jdk-17/bin/java.exe");
// 手动名单也在(与设置同一个 config/ 目录下的纯文本)。
let jdk = fake_jdk(&dir, "keep-me");
std::fs::write(store::manual_file(&file), format!("{}\n", jdk.display())).expect("写名单");
assert_eq!(store::load_manual(&file).len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
/// 「为什么选它」那一行:版本门槛来自实例,选中的运行时够不够要能算出来。
#[test]
fn the_requirement_explains_why_a_runtime_fits_or_not() {
let mut state = State {
scanned: true,
runtimes: vec![
RuntimeRow {
binary: "C:/jdk-8/bin/java.exe".to_string(),
version: String::new(),
major: Some(8),
vendor: None,
source: "system",
},
RuntimeRow {
binary: "C:/jdk-21/bin/java.exe".to_string(),
version: String::new(),
major: Some(21),
vendor: None,
source: "system",
},
],
selected: "C:/jdk-8/bin/java.exe".to_string(),
..State::default()
};
assert_eq!(state.satisfied(Some(8)), None, "没有要求时不下结论");
state.context.required = Some(Requirement {
instance: "生存服".to_string(),
version: "1.20.1".to_string(),
minimum: 17,
maximum: None,
});
assert_eq!(state.satisfied(Some(8)), Some(false));
assert_eq!(state.satisfied(Some(21)), Some(true));
assert_eq!(
state.default_row().map(|row| row.binary.clone()),
Some("C:/jdk-8/bin/java.exe".to_string()),
"默认那一行按 selected 找出来"
);
state.context.layer = Some(Layer::Instance {
instance: "生存服".to_string(),
mb: 8192,
});
assert!(matches!(state.layer(), Layer::Instance { mb: 8192, .. }));
}
/// 截图种子:三档都得给得出来(`--screenshot` 的取证开关)。
#[test]
fn the_screenshot_seeds_cover_the_three_states() {
let empty = State::screenshot_seed("empty").expect("empty 档");
assert!(empty.runtimes.is_empty() && empty.scanned);
let two = State::screenshot_seed("two").expect("two 档");
assert_eq!(two.runtimes.len(), 2);
assert!(two.default_row().is_some(), "两个 JDK 里要选中其一");
let editing = State::screenshot_seed("editing").expect("editing 档");
assert!(editing.dirty, "编辑态要有未保存改动");
assert!(matches!(editing.notice, Some(Notice::Refused(_))));
assert!(State::screenshot_seed("nonsense").is_none());
}
/// 自动扫描的判据:首屏扫一次,扫过就不重扫,截图通道永不扫。
///
/// 变异证明:把 `auto_scan_due` 改成恒 `true`,第二、三档立刻红(每次心跳都发起扫描);
/// 改成恒 `false`,第一档红(用户实测的头一条抱怨就是「设置页从来自动检测」)。
#[test]
fn the_auto_scan_predicate_runs_once_and_never_in_screenshots() {
let fresh = store::ScanReport::default();
assert!(
store::auto_scan_due(&fresh, false),
"首屏(没扫过、没在扫)应该自动扫一次"
);
assert!(
!store::auto_scan_due(&fresh, true),
"截图通道不许读本机装了什么"
);
let running = store::ScanReport {
running: true,
..store::ScanReport::default()
};
assert!(!store::auto_scan_due(&running, false), "正在扫就不重入");
let finished = store::ScanReport {
finished: true,
..store::ScanReport::default()
};
assert!(
!store::auto_scan_due(&finished, false),
"扫过一次就不再自动扫(手动「立即扫描」仍可再扫)"
);
// 被取消过的扫描:`finished` 仍是 false(结论作废),但**不许**自动重来——否则
// `launcher.json` 每变一次,心跳就会把用户刚做的取消无声撤销(r2 复审 P2-2)。
let cancelled = store::ScanReport {
cancelled: true,
..store::ScanReport::default()
};
assert!(
!store::auto_scan_due(&cancelled, false),
"取消过一次之后,本进程不再自动扫描"
);
// 手动「立即扫描」走的是 `ScanSlot::start`,不看这个判据 ⇒ 取消不是永久锁死。
assert!(
store::auto_scan_due(&store::ScanReport::default(), false),
"全新进程(没扫过、没取消)仍然自动扫一次"
);
}
@@ -0,0 +1,318 @@
//! `tests.rs` 的第三块(M175 r2):手动名单的落盘 / 扫描槽的并发与取消 / 错误文案的 i18n。
//!
//! 守卫①~④(列表来自真扫盘、非法路径被拒、内存越界、内存真进命令行)在 `tests.rs` 与
//! `tests/cmdline.rs`;这一块是 r1 复审补的三条(P2-3 / P2-5 / P2-2)。
use super::super::state::{Refusal, RuntimeRow, State, Unwritable};
use super::super::store;
use super::{config, fake_jdk, java_bin, root};
// --------------------------------------------------------------- 守卫⑤:手动名单真落盘(P2-3)
/// 手动加一条合法路径 → **从盘上新读一份** → 名单里有它;「重启」之后仍在。
///
/// 为什么用注入的探测函数:`probe_manual` 要真跑 `java -version`,用例不能依赖本机装没装 JDK;
/// 但**落盘那一段走的是生产代码**(`manual::save_paths` + `manual::load_paths`),所以这条
/// 是「持久化」的正面断言,而不是「内存里的 vec 有它」。
///
/// 变异证明:删掉 `store::add_manual_with` 里的 `manual::save_paths(...)` 那一行 ⇒ 这条红
/// (`load_manual` 从盘上读回 0 条);把去重那段删掉 ⇒ 末尾那条 `AddDuplicate` 断言红。
#[test]
fn a_manual_path_is_written_to_disk_and_survives_a_restart() {
use unlucky_core::game::java::{JavaOrigin, JavaRuntime};
let dir = root("manual-ok");
let file = config(&dir);
let jdk = fake_jdk(&dir, "manual-jdk");
let binary = jdk.join("bin").join(java_bin());
let runtime = JavaRuntime {
binary: binary.clone(),
origin: JavaOrigin::System,
version: Some("openjdk version \"21.0.4\" 2024-07-16 LTS".to_string()),
major: Some(21),
bits: 64,
vendor: Some("Temurin".to_string()),
};
let probe = |_input: &str| Ok(runtime.clone());
// 刚加之前:盘上什么都没有。
assert!(store::load_manual(&file).is_empty());
let (added, row) = store::add_manual_with(&file, &jdk.display().to_string(), &probe)
.expect("合法路径必须加得进去");
assert_eq!(added, binary.display().to_string());
assert_eq!(row.major, Some(21));
// **从盘上新读一份**(不是看内存里的 vec)。
let listed = store::load_manual(&file);
assert_eq!(listed.len(), 1, "名单必须真的写到了盘上:{listed:?}");
assert_eq!(listed[0], binary.display().to_string());
assert!(
store::manual_file(&file).is_file(),
"名单文件必须真的存在:{:?}",
store::manual_file(&file)
);
// 「重启」:一个全新的 State 从盘上灌,名单还在。
let reborn = State {
manual: store::load_manual(&file),
..State::default()
};
assert_eq!(reborn.manual.len(), 1);
// 再加同一份 ⇒ 重复(去重也走盘上的名单)。
assert_eq!(
store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(),
Some(Refusal::AddDuplicate)
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 守卫⑥:扫描只许一个线程(P2-5)
static JOB_LIVE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static JOB_PEAK: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static JOB_RELEASE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// 一个「卡住」的扫描作业:直到被取消或 `JOB_RELEASE` 放开才返回。
fn blocking_job(
_candidates: &[unlucky_core::game::java::staged::Candidate],
cancelled: &dyn Fn() -> bool,
_progress: &mut dyn FnMut(usize, usize),
) -> Vec<RuntimeRow> {
use std::sync::atomic::Ordering;
let live = JOB_LIVE.fetch_add(1, Ordering::SeqCst) + 1;
JOB_PEAK.fetch_max(live, Ordering::SeqCst);
while !JOB_RELEASE.load(Ordering::SeqCst) && !cancelled() {
std::thread::sleep(std::time::Duration::from_millis(10));
}
JOB_LIVE.fetch_sub(1, Ordering::SeqCst);
Vec::new()
}
/// 扫描进行中点「立即扫描」不许起第二个线程;「取消」必须让线程**真的退出**(之后才允许再扫)。
///
/// 变异证明:把 `start_with` 开头那道 `if report.running { return false }` 删掉 ⇒ 第二次
/// `start_with` 返回 `true`、`JOB_PEAK` 变成 2,这条红;把 `cancel()` 里置 `cancelled` 那两行
/// 删掉 ⇒ 作业永远看不到取消、`report().running` 不退,超时断言红。
#[test]
fn only_one_scan_thread_can_run_and_cancel_finishes_it() {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
JOB_RELEASE.store(false, Ordering::SeqCst);
JOB_PEAK.store(0, Ordering::SeqCst);
let slot = store::ScanSlot::default();
assert!(
slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
"第一次扫描应该能起来"
);
assert!(
!slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
"扫描进行中不许起第二个线程"
);
assert!(slot.report().running, "线程活着时 running 必须为真");
slot.cancel();
assert!(slot.report().cancelled, "取消之后要标记 cancelled");
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!slot.report().running,
"取消必须让扫描线程在预算内真的退出(不是只把界面翻成「已取消」)"
);
assert!(!slot.report().finished, "被取消的扫描不许把结论当真");
assert_eq!(
JOB_PEAK.load(Ordering::SeqCst),
1,
"任何时刻只有一个扫描线程"
);
// 线程真退出之后允许重新扫(这就是「取消 ≠ 永久锁死」)。
JOB_RELEASE.store(true, Ordering::SeqCst);
assert!(
slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
"上一个线程退出之后应该能重新扫"
);
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(slot.report().finished, "这次应该正常扫完");
JOB_RELEASE.store(false, Ordering::SeqCst);
}
// --------------------------------------------------------------- 守卫⑦:错误文案是 i18n 的(P2-2)
/// 用户可见的失败原因**全部**来自 catalog:三档「写不进去」各有各的一句,手动路径的
/// 「太长 / 写不进名单」也不再顶替「路径是空的 / 路径上没有东西」。
///
/// 抓得住:①三档文案串台(只读说成「没有改动」);②新加的原因复用旧 key;③把中文硬编码回
/// `store.rs`(源码扫描那一段)。
/// 抓不住:译文翻得好不好(那是金表与人工的事)。
///
/// 变异证明:`Unwritable::ReadOnly` 的 key 改成 `SettingsJavaSkipNotDirty` ⇒ 这条红;
/// 把 `store.rs` 里加回一句硬编码中文 ⇒ 源码扫描那段红。
#[test]
fn the_unwritable_reasons_have_distinct_text() {
use unlucky_i18n::{Key, Lang};
let en = Lang::En.catalog();
let zh = Lang::ZhCn.catalog();
let reasons = [
Unwritable::NotDirty,
Unwritable::ReadOnly,
Unwritable::Unreadable,
];
for reason in reasons {
assert_ne!(
en.text(reason.label()),
zh.text(reason.label()),
"{} 未翻译",
reason.label().name()
);
}
for (index, left) in reasons.iter().enumerate() {
for right in reasons.iter().skip(index + 1) {
assert_ne!(
en.text(left.label()),
en.text(right.label()),
"「写不进去」的两档用了同一句文案:{} / {}",
left.label().name(),
right.label().name()
);
}
}
// 手动路径的两档新原因不许顶替旧原因(P2-2 的第三条与第四条)。
assert_eq!(
Refusal::AddTooLong.label(),
Key::SettingsJavaReasonAddTooLong
);
assert_eq!(
Refusal::AddWriteFailed.label(),
Key::SettingsJavaReasonAddWriteFailed
);
assert_ne!(Refusal::AddTooLong.label(), Refusal::AddEmpty.label());
assert_ne!(Refusal::AddWriteFailed.label(), Refusal::AddMissing.label());
// 源码扫描:`store.rs` 里不许再有「把 Rust 枚举名或中文字符串当人话」的写法。
let source = include_str!("../store.rs");
assert!(
!source.contains("format!(\"{refusal:?}\")"),
"不许把 Rust 枚举名(Refusal 的 Debug 串)当用户文案"
);
for hardcoded in ["没有改动", "配置文件只读", "配置文件不可信"] {
// 只认**字符串字面量**(带引号):文档注释里出现这几个词是正常的说明。
let literal = format!("\"{hardcoded}\"");
assert!(
!source.contains(&literal),
"store.rs 里还有硬编码中文字符串:{literal}"
);
}
}
/// `core` 的每种拒绝原因都要落到**自己的**页面原因上(P2-2 的第三、四条)。
///
/// 抓得住:`ManualFailure::TooLong` 被写成 `Refusal::AddEmpty`(界面会对一条 600 字符的路径说
/// 「路径是空的」)、名单写盘失败被写成 `Refusal::AddMissing`(磁盘写不进去却说「路径上没有东西」)。
/// 抓不住:文案翻得好不好(那是金表与人工的事)。
///
/// 变异证明:`failure_refusal` 里 `TooLong => AddTooLong` 改成 `AddEmpty` ⇒ 本条红;
/// 写盘失败那行 `map_err(|_| AddWriteFailed)` 改成 `AddMissing` ⇒ 本条红。
#[test]
fn every_manual_failure_maps_to_its_own_reason() {
use unlucky_core::game::java::manual::ManualFailure;
let dir = root("manual-map");
let file = config(&dir);
for (failure, expected) in [
(ManualFailure::Empty, Refusal::AddEmpty),
(ManualFailure::TooLong, Refusal::AddTooLong),
(ManualFailure::Missing, Refusal::AddMissing),
(ManualFailure::NotJava, Refusal::AddNotJava),
(ManualFailure::NoVersion, Refusal::AddNoVersion),
] {
let probe = move |_input: &str| Err(failure);
assert_eq!(
store::add_manual_with(&file, "whatever", &probe).err(),
Some(expected),
"{failure:?} 应该映射到 {expected:?}"
);
}
// 一个都没写进去。
assert!(store::load_manual(&file).is_empty());
std::fs::remove_dir_all(&dir).ok();
}
/// 名单文件写不进去(父路径是个文件)时,报的是「写不进名单」而不是「路径上没有东西」。
#[test]
fn a_failed_list_write_reports_the_write_not_the_path() {
use unlucky_core::game::java::{JavaOrigin, JavaRuntime};
let dir = root("manual-write-fail");
// `<dir>/config` 做成**文件**:`manual_file` 的父目录建不出来 ⇒ save_paths 失败。
// (`root()` 会先建好 `config/`,所以这里先删掉再放一个同名文件。)
let blocker = dir.join("config");
std::fs::remove_dir_all(&blocker).expect("删掉空 config 目录");
std::fs::write(&blocker, b"not a directory").expect("写占位文件");
let file = blocker.join("launcher.json");
let jdk = fake_jdk(&dir, "manual-jdk");
let binary = jdk.join("bin").join(java_bin());
let probe = |_input: &str| {
Ok(JavaRuntime {
binary: binary.clone(),
origin: JavaOrigin::System,
version: Some("openjdk version \"17.0.13\" 2024-10-15".to_string()),
major: Some(17),
bits: 64,
vendor: Some("Temurin".to_string()),
})
};
assert_eq!(
store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(),
Some(Refusal::AddWriteFailed),
"磁盘写不进去要说「写不进名单」,不能说「路径不存在」"
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- r2 P2-1:空态不许指向不存在的页
/// 空态给的那条「还有别的办法」必须是**真的存在**的入口。
///
/// 现场(r2 复审 P2-1):原文写「或者去 Java 页装一个」,而 GUI 里根本没有 Java 页
/// (`presenter::router::Route` 只有 Instances / Download / Content / Logs / Settings /
/// Accounts / Detail / Agent,`spawn_install` 在 `crates/app-iced/src` 里零消费者)。
/// 于是「一个 JDK 都没装」的用户——正是这个 mission 要救的那位——被指到一个不存在的入口。
///
/// 抓得住:文案被改回「Java 页 / the Java page」,或把命令行入口那半句删掉。
/// 抓不住:命令行的措辞好不好读(那是人工的事)。
///
/// 变异证明:把 `SettingsJavaEmptyBody` 改回「或者去 Java 页装一个」⇒ 本条红。
#[test]
fn the_empty_state_points_at_a_real_entry() {
use unlucky_i18n::Lang;
for lang in [Lang::En, Lang::ZhCn] {
let text = lang
.catalog()
.text(unlucky_i18n::Key::SettingsJavaEmptyBody);
assert!(
text.contains("ull java install"),
"{}:空态要给一条真能走的路(命令行)——{text}",
lang.tag()
);
for dead in ["Java page", "Java 页"] {
assert!(
!text.contains(dead),
"{}:空态指向了不存在的入口「{dead}」——{text}",
lang.tag()
);
}
}
}
@@ -0,0 +1,284 @@
//! 「Java」分组的一张卡片(M175)。
//!
//! ## 版面(自上而下)
//!
//! | 行 | 内容 | 数值 / 证据 |
//! |---|---|---|
//! | 状态 | 未检测到 / N 个 JDK / 正在扫描 3/7 | 扫描按钮与取消按钮同步禁用 |
//! | 运行时列表 | 每个 JDK 一行:路径 + 版本 + 发行方 + 来源 | 「默认」标记 / 「设为默认」chip |
//! | 版本门槛 | 当前实例需要 Java ≥ N,哪一行不满足 | 来自 app 层算好的 [`Context`] |
//! | 手动添加 | 输入框 + 「添加」 | 校验不过时下面直接说原因 |
//! | 默认内存 | 数字框 + 范围提示 + 「现在生效多少来自哪一层」 | 落盘值(不是草稿) |
//! | JVM 参数 | 文本框 + 拼命令行提示 | 落盘的 `java.extra_args` |
//! | 动作 | 保存 | 灰掉时下面是原因 |
//!
//! 文案一律走 [`unlucky_i18n::Catalog`]:本组要 `Lang`,所以走 `settings/mod.rs` 的按下标分发
//! (与 General / Update / Assistant 同一条路),`Group.body` 保持 `None`。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::{card, divider, text_role_owned};
use crate::pages::Message;
use crate::theme::Ui;
use super::fields::{actions_row, add_row, hint_line, jvm_field, memory_field, notice_line};
use super::state::{Progress, RuntimeRow, State};
use super::Message as JavaMessage;
use crate::pages::settings::{GroupMessage, States};
/// 一条本组消息 → 页面消息。
pub(super) fn msg(message: JavaMessage) -> Message {
Message::Settings(GroupMessage::Java(message))
}
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来)。
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
body_of(ui, &states.groups.java, lang)
}
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
stacked = stacked.push(status_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(runtime_list(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(add_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(memory_field(ui, catalog, state));
stacked = stacked.push(jvm_field(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(actions_row(ui, catalog, state));
stacked = stacked.push(hint_line(ui, catalog, state));
if let Some(notice) = &state.notice {
stacked = stacked.push(notice_line(ui, catalog, notice));
}
card(ui, stacked)
}
/// 状态行:未检测到 / N 个 JDK / 正在扫描 / 正在取消 + 两个按钮。
///
/// 「取消」的语义(r1 复审 P2-5):按下之后到线程真退出之间,界面显示的是**正在取消…**,
/// 「立即扫描」保持禁用——否则用户能在旧线程还活着的时候起第二个。
fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
let (text, scanning) = match (state.scanning, state.scanned) {
(Some(_), _) if state.cancelled => {
(catalog.text(Key::SettingsJavaCancelling).to_string(), true)
}
// 候选表还在收集(`total == 0`):不显示 `0/0`,给一句「正在扫描…」。
(Some(Progress { total: 0, .. }), _) => (
catalog.text(Key::SettingsJavaScanningStart).to_string(),
true,
),
(Some(Progress { done, total }), _) => (
catalog.render(
Key::SettingsJavaScanning,
&[
("done", done.to_string().as_str()),
("total", total.to_string().as_str()),
],
),
true,
),
(None, true) if state.runtimes.is_empty() => {
(catalog.text(Key::SettingsJavaEmptyTitle).to_string(), false)
}
(None, true) => (
catalog.render(
Key::SettingsJavaScanDone,
&[("count", state.runtimes.len().to_string().as_str())],
),
false,
),
(None, false) => (
catalog.text(Key::SettingsJavaNeverScanned).to_string(),
false,
),
};
let scan_label = catalog.text(Key::SettingsJavaScan);
let scan = if scanning || !state.storage {
buttons::filled(ui, Button::disabled(scan_label))
} else {
buttons::filled(ui, Button::new(scan_label, msg(JavaMessage::Scan)))
};
// 已经按过取消(线程还没退出)时,「取消」也灰掉:再按一次没有任何意义。
let cancel = buttons::tonal(
ui,
if scanning && !state.cancelled {
Button::new(
catalog.text(Key::SettingsJavaCancel),
msg(JavaMessage::CancelScan),
)
} else {
Button::disabled(catalog.text(Key::SettingsJavaCancel))
},
);
// 「扫描已取消」那句只在线程**真退出之后**出现;退出之前状态行本身写着「正在取消…」。
let cancelled = (state.cancelled && state.scanning.is_none()).then(|| {
text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsJavaCancelled).to_string(),
ui.color(Role::OnSurfaceVariant),
)
});
let mut block = column![row![
container(text_role_owned(
ui,
TypeRole::BodyMedium,
true,
text,
ui.color(if state.scanning.is_some() {
Role::Primary
} else {
Role::OnSurface
}),
))
.width(Length::Fill),
scan,
cancel,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center),]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if let Some(line) = cancelled {
block = block.push(line);
}
block.into()
}
/// 运行时列表:每行一个 JDK,带「默认」标记与「设为默认」chip。
fn runtime_list<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let mut list = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
if state.runtimes.is_empty() {
let body = if state.scanned {
catalog.text(Key::SettingsJavaEmptyBody)
} else {
catalog.text(Key::SettingsJavaNeverScannedBody)
};
return text_role_owned(
ui,
TypeRole::BodySmall,
false,
body.to_string(),
ui.color(Role::OnSurfaceVariant),
);
}
for runtime in &state.runtimes {
list = list.push(runtime_row(ui, catalog, state, runtime));
}
list.into()
}
fn runtime_row<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
runtime: &'a RuntimeRow,
) -> Element<'a, Message> {
let is_default = super::state::same_path(&runtime.binary, &state.selected);
let satisfied = state.satisfied(runtime.major);
let (verdict, verdict_color) = match satisfied {
Some(true) => (
catalog.text(Key::SettingsJavaFits).to_string(),
ui.color(Role::Primary),
),
Some(false) => {
let required = state
.context
.required
.as_ref()
.map(|required| required.minimum.to_string())
.unwrap_or_default();
(
catalog.render(Key::SettingsJavaTooOld, &[("minimum", required.as_str())]),
ui.color(Role::Error),
)
}
None => (String::new(), ui.color(Role::OnSurfaceVariant)),
};
let action = if is_default {
chip(
ui,
// 已选中那颗是**纯展示**的 badge(没有消息,不可交互):r2 复审要求清掉
// 「禁用态 chip 上还挂着一条真消息」的残留。
Chip::badge(catalog.text(Key::SettingsJavaDefault)),
)
} else {
chip(
ui,
Chip::filter(
catalog.text(Key::SettingsJavaSetDefault),
false,
msg(JavaMessage::SelectRuntime(runtime.binary.clone())),
),
)
};
let mut block = column![row![
container(
column![
text_role_owned(
ui,
TypeRole::BodyMedium,
is_default,
runtime.display_home(),
ui.color(Role::OnSurface),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
format!(
"{} · {}",
runtime.summary(),
catalog.text(runtime.source_key())
),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
)
.width(Length::Fill),
action,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !verdict.is_empty() {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
verdict,
verdict_color,
));
}
block.into()
}
@@ -17,7 +17,7 @@
//! | `crates/i18n/src/catalog/settings/general.rs` / `appearance.rs` | 两组的文案 key |
//! | `crates/app-iced/src/app/update/settings/language_appearance.rs` | 消息的落点 |
//!
//! 真控件那半边在 `pages/appearance/section.rs`(`appearance.rs` 只负责把 `body` 挂上去)。
//! 真控件那半边在 `pages/appearance/section/`(`appearance.rs` 只负责把 `body` 挂上去)。
/// 语言 + 外观线的状态(**只归本线**)。
///
@@ -0,0 +1,124 @@
//! 设置页「General」组的**日志文件夹**一行(M188)。
//!
//! 用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。
//! 这一行是对那句投诉的直接回答,三件事缺一不可:
//!
//! 1. **路径永远显示**(不是只在打开成功时才显示):`<数据根>/logs`,三模式解析出来的真值;
//! 2. **两个入口**:「打开文件夹」走平台打开器(`app::dialog::export::open_dir`,与崩溃弹窗同一个,
//! 不另写一套 `cfg`),「复制路径」把路径送进剪贴板——打开失败时用户至少能照着路径自己去找;
//! 3. **说清里面有什么**:`launcher.log`(滚动上限写进文案)与 `logs/game/` 的会话 jsonl,
//! 以及旧目录「仍能读、没搬动」——否则用户换了版本会以为旧日志丢了。
//!
//! 为什么单独一个文件:`pages/settings/general.rs` 贴着 400 行硬线(语言行 + 字体行 + 三行只读),
//! 这一行自带按钮与样式,塞回去必破线。
use iced::widget::{button, column, container, row as row_widget};
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
use unlucky_design::color::{Role, StateLayer};
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::{Catalog, Key};
use crate::pages::{frame, Action, Message};
use crate::theme::Ui;
/// 这一行(竖排:标签 / 路径 / 两个按钮 / 一句说明)。
pub(super) fn folder_row<'a>(ui: &'a Ui, catalog: &Catalog) -> Element<'a, Message> {
let path = crate::app::logs_folder::logs_dir()
.map(|dir| dir.display().to_string())
.unwrap_or_else(|| catalog.text(Key::SettingsLogsUnknown).to_string());
let hint = catalog.text(Key::SettingsLogsFolderHint);
let actions = row_widget![
action_button(
ui,
catalog.text(Key::LogsOpenFolder),
Message::Action(Action::OpenLogs),
),
action_button(
ui,
catalog.text(Key::SettingsLogsCopy),
Message::CopySummary(path.clone()),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
column![
frame_text(
ui,
TypeRole::BodyMedium,
catalog.text(Key::SettingsLogsFolder),
ui.color(Role::OnSurface),
),
container(frame_text_owned(
ui,
TypeRole::BodySmall,
path,
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill),
actions,
frame_text(
ui,
TypeRole::BodySmall,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
fn frame_text<'a>(
ui: &'a Ui,
role: TypeRole,
text: &'a str,
color: iced::Color,
) -> Element<'a, Message> {
frame::text_role(ui, role, false, text, color)
}
fn frame_text_owned<'a>(
ui: &'a Ui,
role: TypeRole,
text: String,
color: iced::Color,
) -> Element<'a, Message> {
frame::text_role_owned(ui, role, false, text, color)
}
/// 描边小按钮(与设置页其他「次级动作」同款:透明底、hover 出状态层、全圆角)。
fn action_button<'a>(ui: &'a Ui, label: &'a str, message: Message) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content = ui.color(Role::OnSurface);
let outline = ui.color(Role::OutlineVariant);
button(
container(frame_text(ui, TypeRole::LabelLarge, label, content)).align_y(Alignment::Center),
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(move |_theme: &IcedTheme, status| {
let background =
matches!(status, button::Status::Hovered | button::Status::Pressed).then(|| {
Background::Color(ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface))
});
button::Style {
background,
text_color: content,
border: Border {
color: outline,
width: 1.0,
radius: ui.radius(ShapeScale::Full, height),
},
shadow: Shadow::default(),
snap: true,
}
})
.on_press(message)
.into()
}
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff. Show more