Files
Unlucky-Launcher/crates/core/tests/setting/appearance_keys.rs
T
AskaEth 88dbf6d955 feat(app-iced): M116 窗口外框圆角策略(跟随系统/直角)+ DWM 读写的受控对照实验
用户问「为什么窗口没有圆角」。核实:组件层圆角齐,但窗口外框**零处理**——代码里没有任何
Dwm* / SetWindowRgn。本 mission 先做实测,再按平台分开落策略。

判据(纯函数、可真值表):
- `platform::plan_corner(策略 × 系统版本 × 渲染档)` → `source=dwm|square` + `excluded_reason`。
  Win11 / Server 2025(build ≥ 22000)→ DWM;Win10 及更早 → 直角(不做 SetWindowRgn 毛边默认);
  探测不到版本(macOS / X11 / Wayland)→ 直角 + `unknown-os`(「不承诺」而不是「做不到」)。
  CPU 档在 Win11 上照样用 DWM(D36 限制的是我们自己的合成技巧,不是系统合成器)。

落点:`platform/win32/transparency.rs` 仍是全仓唯一 unsafe 文件,新增 DWM 的 FFI 与安全包装;
`alpha.rs` / `dwm.rs` / `probe.rs` 三个新子模块零 unsafe、零 extern(沿 exstyle.rs 的既有形状)。

实测(本机 Server 2025 build 26100、RDP 会话断开、同进程三窗口对照):
- `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 在 WS_OVERLAPPEDWINDOW / WS_POPUP /
  WS_POPUP|WS_EX_LAYERED(α=0.8) 上**全部成功** ⇒ 无边框与整窗 α 不会让属性设不上;
- `DwmGetWindowAttribute` 同一属性三种样式**全部 E_INVALIDARG** ⇒ 本机不支持回读,与样式无关;
- `DWMWA_EXTENDED_FRAME_BOUNDS − GetWindowRect`:有边框 = +0,−7,popup = +0,+0
  ⇒ DWM 只给有边框窗口加不可见调整带,我们的窗口没有可画的圆角区域(结构性解释)。
- 像素(角上透明/方块)**未验**:RDP 断连导致 CopyFromScreen / gdigrab 全废。

同时修掉一个自己踩到的判据错误:`i32::unsigned_abs()` 不能用来打印 HRESULT
(E_INVALIDARG 0x80070057 会被印成 0x7FF8FFA9)。现在 HRESULT 用 `hr as u32` + 严重性位,
GetLastError 的 DWORD 语义保持 `unsigned_abs` 并写死注释(两者不同源,别统一)。

配置:新键 `appearance.window_corner ∈ {system, square}`(出厂 system),schema 1.5.0 只推 minor、
不写迁移:老文件缺键 = 取默认 = 跟随系统;旧版本读新文件时未知键被登记并原样写回(`RenderMode::Full`),
只在显式 `store.set` 该键时才 `UnknownKey`。

设置 UI:外观组只留两档(跟随系统 / 直角)+ 一句按平台说清的说明,文案走 i18n 中英。
日志:`[corner] <ctx> corner=policy=… source=… os=… excluded_reason=… radius=system pref=… readback=… frame=… layered=…`
(形状由 `corner::format_line` 的格式化单测钉住,用探针窗口真实读数构造)。

证据:DevDocs/artifacts/m116/{README.md,probe-table.md}
2026-11-05 06:35:03 +08:00

205 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 「外观(高级)」键簇(`setting::appearance` + `keys::launcher_schema` 的 appearance.* 段)。
//!
//! 守三条设计约束(09-ui-layout §111):
//! 1. 一切高级特性**默认关闭**(出厂 Metro 纯色)——默认值即出厂态;
//! 2. 全部走 D6 schema 且 **AI 可写**(白名单内,可被 agent 调节);
//! 3. 值域有硬边界(滑条夹取),坏值/手改配置不崩(回落默认)。
use serde_json::json;
use unlucky_core::setting::appearance::{
hex_to_rgb, normalize_hex, relative_luminance, Appearance, BgFill, DEFAULT_ACCENT,
PANEL_ALPHA_MIN,
};
use unlucky_core::setting::document::SettingDocument;
use unlucky_core::setting::keys::{launcher_schema, launcher_version};
use unlucky_core::setting::spec::ValueKind;
fn doc(raw: serde_json::Value) -> SettingDocument {
SettingDocument::load(&launcher_schema(), &raw).0
}
#[test]
fn factory_defaults_are_all_advanced_features_off() {
let appearance = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load(
std::env::temp_dir().join(format!("ull-appearance-none-{}.json", std::process::id())),
launcher_schema(),
));
assert_eq!(appearance.theme_mode, "auto", "D17:出厂跟随系统");
assert!(!appearance.bg_image_enabled, "背景图默认关");
assert!(!appearance.bg_image_active());
assert_eq!(appearance.bg_fill, BgFill::Cover);
assert_eq!(appearance.panel_alpha, 1.0, "出厂完全不透明(Metro 纯色)");
assert!(!appearance.glass, "玻璃拟态默认关");
assert_eq!(appearance.accent_color, "", "空串 = 未自定义");
assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT);
}
#[test]
fn reading_a_configured_document_reflects_each_key() {
let document = doc(json!({
"appearance": {
"theme_mode": "dark",
"bg_image_enabled": true,
"bg_image": "D:/wall/paper.png",
"bg_fill": "tile",
"bg_dim": 0.6,
"panel_alpha": 0.8,
"glass": true,
"accent_color": "#ff5722",
}
}));
let appearance = Appearance::read(&document);
assert_eq!(appearance.theme_mode, "dark");
assert!(appearance.bg_image_active());
assert_eq!(appearance.bg_image, "D:/wall/paper.png");
assert_eq!(appearance.bg_fill, BgFill::Tile);
assert!((appearance.bg_dim - 0.6).abs() < 1e-9);
assert!((appearance.panel_alpha - 0.8).abs() < 1e-9);
assert!(appearance.glass);
assert_eq!(appearance.accent_color, "#FF5722", "读入即规范化大写");
assert_eq!(appearance.accent_or_default(), "#FF5722");
}
#[test]
fn bad_values_fall_back_instead_of_panicking() {
// 手改坏的配置:枚举拼错 / 开关类型错 / accent 不是 #RRGGBB。
let document = doc(json!({
"appearance": {
"bg_fill": "stretch",
"bg_image_enabled": "yes",
"accent_color": "rebeccapurple",
}
}));
let appearance = Appearance::read(&document);
assert_eq!(appearance.bg_fill, BgFill::Cover);
assert!(!appearance.bg_image_enabled);
assert_eq!(appearance.accent_color, "");
assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT);
}
#[test]
fn panel_alpha_is_clamped_to_the_contrast_floor() {
// schema 下限 0.4;读侧再夹一次(挡手改文件绕过 schema 的情况)。
let document = doc(json!({ "appearance": { "panel_alpha": 0.05 } }));
assert!((Appearance::read(&document).panel_alpha - PANEL_ALPHA_MIN).abs() < 1e-9);
}
#[test]
fn appearance_keys_are_all_ai_writable_and_registered() {
let schema = launcher_schema();
// 主题三态是 D17 已定案的最轻量键(silent/Low),其余高级键留审计痕迹(audited/Medium)。
let audited_keys = [
"appearance.bg_image_enabled",
"appearance.bg_image",
"appearance.bg_fill",
"appearance.bg_dim",
"appearance.panel_alpha",
"appearance.glass",
"appearance.accent_color",
// M91(D36/D37/D38)的外观下层三键,同样 audited。
"appearance.window_opacity",
"appearance.titlebar_mode",
"appearance.palette_source",
];
for key in audited_keys {
let spec = schema.spec(key).unwrap_or_else(|| panic!("{key} 必须登记"));
assert!(spec.ai.writable, "{key} 必须在 AI 可写白名单内");
// 纯视觉、可一键复原 → 不要求弹窗确认(High 级留给凭据/数据根/代理)。
assert!(
!spec.ai.sensitivity.requires_confirmation(),
"{key} 不该要确认"
);
assert_eq!(
spec.ai.sensitivity.as_str(),
"medium",
"{key} 要留审计痕迹(AI 改过什么可回看)"
);
}
// 主题三态仍是 silent(D17 已定案的最轻量键)。
assert_eq!(
schema
.spec("appearance.theme_mode")
.unwrap()
.ai
.sensitivity
.as_str(),
"low"
);
}
#[test]
fn schema_registration_shape_is_right() {
assert_eq!(
launcher_version().minor,
5,
"1.5.0 登记 M116 的窗口圆角策略键"
);
let schema = launcher_schema();
assert_eq!(
schema.spec("appearance.bg_fill").unwrap().kind,
ValueKind::Enum(&["cover", "contain", "tile"])
);
assert!(matches!(
schema.spec("appearance.panel_alpha").unwrap().kind,
ValueKind::Float { min, max } if (min - PANEL_ALPHA_MIN).abs() < 1e-9 && (max - 1.0).abs() < 1e-9
));
assert_eq!(
schema.spec("appearance.accent_color").unwrap().kind,
ValueKind::Text { max_len: 9 },
"#RRGGBB 最多 7 字符,留 9 容错带井号与空白"
);
// 默认值 = 出厂态
assert_eq!(
schema.spec("appearance.panel_alpha").unwrap().default,
serde_json::Value::from(1.0)
);
assert_eq!(
schema.spec("appearance.glass").unwrap().default,
serde_json::Value::from(false)
);
}
#[test]
fn write_round_trips_through_the_store() {
let temp =
std::env::temp_dir().join(format!("ull-appearance-{}-{}", std::process::id(), line!()));
std::fs::create_dir_all(temp.join("config")).expect("临时目录");
let path = temp.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
let mut store = unlucky_core::setting::store::SettingStore::load(&path, launcher_schema());
let mut appearance = Appearance::from_store(&store);
appearance.theme_mode = "dark".to_owned();
appearance.panel_alpha = 0.8;
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/p.png".to_owned();
appearance.accent_color = "#0f6cbd".to_owned();
appearance.write(&mut store).expect("写外观键");
store.save().expect("落盘");
let reloaded = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load(
&path,
launcher_schema(),
));
assert_eq!(reloaded.theme_mode, "dark");
assert!((reloaded.panel_alpha - 0.8).abs() < 1e-9);
assert!(reloaded.bg_image_active());
assert_eq!(reloaded.accent_color, "#0F6CBD");
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn hex_helpers_validate_and_convert() {
assert_eq!(normalize_hex(" 0f6cbd "), Some("#0F6CBD".to_owned()));
assert_eq!(normalize_hex("#0F6CBD"), Some("#0F6CBD".to_owned()));
assert_eq!(normalize_hex("#0F6CB"), None);
assert_eq!(normalize_hex("#GGGGGG"), None);
assert_eq!(hex_to_rgb("#0078D4"), Some((0x00, 0x78, 0xD4)));
// WCAG 相对亮度:纯黑 0、纯白 1、Metro 蓝居中偏暗。
assert!(relative_luminance("#000000").unwrap().abs() < 1e-9);
assert!((relative_luminance("#FFFFFF").unwrap() - 1.0).abs() < 1e-9);
let metro = relative_luminance(DEFAULT_ACCENT).unwrap();
assert!(metro > 0.05 && metro < 0.3, "Metro 蓝亮度 {metro}");
}