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}
This commit is contained in:
AskaEth committed 2026-11-05 06:35:03 +08:00
1 parent aace0c5836
commit 88dbf6d955
29 files changed
+2047 -259

No files matched your search

+17 -1
View File
@@ -15,6 +15,7 @@
//! | `appearance.window_opacity` | `0.08..=1.0` | `1.0` | 负责钳位;**不**调 FFI(落点在 `app-iced` 的 win32 层) |
//! | `appearance.titlebar_mode` | `auto` / `always` / `never` | `auto` | 只存值;`auto` 的判定属于 UI 侧(WCHROME-IMPL) |
//! | `appearance.palette_source` | `background-image` / `wallpaper` / `preset` | `preset` | 只存档位;三级编排在 `unlucky-design`(D38) |
//! | `appearance.window_corner` | `system` / `square` | `system` | 只存策略;「这台机器能不能圆」的判定在 `app-iced` 的 `platform`(M116) |
//!
//! 非法枚举**不报错**但也**不静默**:[`diagnostics`] 给出「读到什么 → 生效什么」的可读说明。
@@ -26,7 +27,8 @@ use super::store::SettingStore;
mod modes;
pub use modes::{
diagnostics, AppearanceDiagnostic, PaletteSource, TitlebarMode, PALETTE_SOURCES, TITLEBAR_MODES,
diagnostics, AppearanceDiagnostic, PaletteSource, TitlebarMode, WindowCorner, PALETTE_SOURCES,
TITLEBAR_MODES, WINDOW_CORNERS,
};
#[cfg(test)]
@@ -114,6 +116,11 @@ pub struct Appearance {
pub titlebar_mode: TitlebarMode,
/// 配色来源档(D38 三级;默认 [`PaletteSource::Preset`] = 内置色卡)。
pub palette_source: PaletteSource,
/// 窗口外框圆角策略(M116;默认 [`WindowCorner::System`] = 跟随系统)。
///
/// 只管用户要什么:真能不能圆由平台决定(Win11/Server 2025 走 DWM,Win10 保持直角),
/// 判定在 `unlucky-app-iced` 的 `platform`,core 不探测系统。
pub window_corner: WindowCorner,
}
impl Default for Appearance {
@@ -130,6 +137,7 @@ impl Default for Appearance {
window_opacity: 1.0,
titlebar_mode: TitlebarMode::Auto,
palette_source: PaletteSource::Preset,
window_corner: WindowCorner::System,
}
}
}
@@ -170,6 +178,10 @@ impl Appearance {
.map_or(default.palette_source, |value| {
PaletteSource::from_key_value(&value)
}),
window_corner: text("appearance.window_corner")
.map_or(default.window_corner, |value| {
WindowCorner::from_key_value(&value)
}),
}
}
@@ -241,6 +253,10 @@ impl Appearance {
"appearance.palette_source",
Value::from(self.palette_source.as_str()),
)?;
store.set(
"appearance.window_corner",
Value::from(self.window_corner.as_str()),
)?;
Ok(())
}
}
+57 -2
View File
@@ -10,6 +10,48 @@ use crate::setting::document::SettingDocument;
/// `appearance.titlebar_mode` 的允许值(D37 三态)。**顺序即设置页分段控件的顺序**。
pub const TITLEBAR_MODES: &[&str] = &["auto", "always", "never"];
/// `appearance.window_corner` 的允许值(M116)。**顺序即设置页分段控件的顺序**。
///
/// 只有两档是**刻意**的:系统不暴露圆角半径(Win32 没有「给我 DWM 的圆角像素值」这种 API),
/// 所以「固定 8/12/16」这类档位只能是自绘圆角,而自绘圆角在 `decorations=false` + 整窗 α 的
/// 窗口上必须裁内容层,会把标题栏三键与缩放热区一起裁掉。用户定案(2026-11-03):只留
/// 「跟随系统 / 直角」。
pub const WINDOW_CORNERS: &[&str] = &["system", "square"];
/// 窗口外框圆角策略(M116)。
///
/// 只表达**用户要什么**;「这台机器能做到什么」的判定在 UI 侧(`app-iced` 的 `platform`):
/// core 不探测系统能力(与 [`TitlebarMode`] 同一条纪律)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WindowCorner {
/// 跟随系统:Win11 / Server 2025 内核(build ≥ 22000)用 DWM 圆角,Win10 保持直角
#[default]
System,
/// 直角:任何平台都不加圆角
Square,
}
impl WindowCorner {
/// 落盘取值。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::System => "system",
Self::Square => "square",
}
}
/// 从落盘取值解析;未知值按 `system` 兜底(手改坏配置也不崩,回落原因由
/// [`diagnostics`] 给出可读诊断)。
#[must_use]
pub fn from_key_value(value: &str) -> Self {
match value {
"square" => Self::Square,
_ => Self::System,
}
}
}
/// 标题栏三态(D37)。
///
/// 只持有「用户想要哪种」,**不含** `auto` 的判定 —— 「非 Wayland → 显示 / 合成器名精确命中
@@ -111,8 +153,8 @@ pub struct AppearanceDiagnostic {
/// 读出需要提示的回落(空 = 一切正常)。
///
/// 只覆盖**枚举型**键(`titlebar_mode` / `palette_source`)。数值键(`window_opacity`)
/// 越界是「钳位且不报错」的既定语义(D36 下限 0.08),不算诊断项。
/// 只覆盖**枚举型**键(`titlebar_mode` / `palette_source` / `window_corner`)。数值键
/// (`window_opacity`)越界是「钳位且不报错」的既定语义(D36 下限 0.08),不算诊断项。
#[must_use]
pub fn diagnostics(doc: &SettingDocument) -> Vec<AppearanceDiagnostic> {
let mut found = Vec::new();
@@ -148,5 +190,18 @@ pub fn diagnostics(doc: &SettingDocument) -> Vec<AppearanceDiagnostic> {
});
}
}
if let Some(raw) = text("appearance.window_corner") {
if !WINDOW_CORNERS.contains(&raw.as_str()) {
let fallback = WindowCorner::System.as_str();
found.push(AppearanceDiagnostic {
key: "appearance.window_corner",
message: format!(
"appearance.window_corner 的值 {raw:?} 不在 {WINDOW_CORNERS:?} 之中,已按 {fallback} 处理"
),
raw,
fallback,
});
}
}
found
}
+39 -2
View File
@@ -6,8 +6,8 @@
use serde_json::json;
use super::{
diagnostics, Appearance, PaletteSource, TitlebarMode, PALETTE_SOURCES, TITLEBAR_MODES,
WINDOW_ALPHA_MIN,
diagnostics, Appearance, PaletteSource, TitlebarMode, WindowCorner, PALETTE_SOURCES,
TITLEBAR_MODES, WINDOW_ALPHA_MIN, WINDOW_CORNERS,
};
use crate::setting::document::SettingDocument;
use crate::setting::keys::launcher_schema;
@@ -176,3 +176,40 @@ fn older_document_loads_and_write_back_keeps_existing_keys() {
std::fs::remove_dir_all(&temp).ok();
}
/// **M116**:圆角键的三条读侧语义——缺键取默认、合法值往返、坏值落回 `system` 并**留诊断**。
///
/// 抓得住:把 `from_key_value` 的兜底写成 `Square`(那会让「配置写坏了」变成「用户想要直角」)、
/// 或者漏掉 `diagnostics` 里那一条(静默回落)。
/// 抓不住:这台机器能不能真圆(那是 UI 侧 `platform::plan_corner` 的事)。
#[test]
fn window_corner_falls_back_to_follow_system_and_says_so() {
let appearance = Appearance::read(&doc(json!({})));
assert_eq!(
appearance.window_corner,
WindowCorner::System,
"缺键 = 出厂 = 跟随系统"
);
assert!(
diagnostics(&doc(json!({}))).is_empty(),
"缺键不是回落,不该有诊断"
);
for corner in [WindowCorner::System, WindowCorner::Square] {
assert_eq!(WindowCorner::from_key_value(corner.as_str()), corner);
assert!(WINDOW_CORNERS.contains(&corner.as_str()));
let read = Appearance::read(&doc(
json!({ "appearance": { "window_corner": corner.as_str() } }),
));
assert_eq!(read.window_corner, corner);
}
// 坏值:不报错、不崩,落到 system,但**必须**留下可读诊断。
let bad = doc(json!({ "appearance": { "window_corner": "rounded" } }));
assert_eq!(Appearance::read(&bad).window_corner, WindowCorner::System);
let notes = diagnostics(&bad);
assert_eq!(notes.len(), 1, "{notes:?}");
assert_eq!(notes[0].key, "appearance.window_corner");
assert_eq!(notes[0].fallback, "system");
assert!(notes[0].message.contains("rounded"), "{}", notes[0].message);
}
+18 -1
View File
@@ -52,9 +52,16 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"];
/// `1.4.0` 登记 P4 的外观下层三键(`appearance.window_opacity` / `titlebar_mode` /
/// `palette_source`,M91 · D36/D37/D38):仍然只推 minor、不加迁移步骤——三者的出厂值
/// (不透明 / auto / 内置色卡)正是「老文件没有这些键」时的等价物。
/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):
/// 同一套纯增量语义——老文件缺该键 ⇒ 读默认 `system` ⇒ 跟随系统,与写进文件后行为一致。
/// **向前兼容(旧版本读新文件)**:本仓的 [`super::catalog`] 是白名单校验,
/// 但读路径 (`SettingDocument::load`) 只**登记**陌生键、不报错也不删除(`RenderMode::Full`
/// 会把它们原样写回),所以旧版本打开新文件不会崩,只是看不到「直角」这一档;
/// 只有**显式 `store.set("appearance.window_corner", …)`** 在旧版本上才会
/// 返回 `SettingError::UnknownKey`(新版本自己不会这么调)。
#[must_use]
pub fn launcher_version() -> SchemaVersion {
SchemaVersion::new(1, 4, 0)
SchemaVersion::new(1, 5, 0)
}
/// `config/launcher.json` 的 schema。
@@ -336,6 +343,16 @@ pub fn launcher_schema() -> SettingSchema {
AiPolicy::audited(),
"配色来源档(D38 三级):background-image(背景图取色)/ wallpaper(桌面壁纸取色)/ preset(内置色卡,默认)",
))
// M116:窗口外框圆角策略。只有两档(system / square)——系统不暴露圆角半径,
// 「固定 8/12/16」只能是自绘圆角,而自绘圆角在无边框 + 整窗 α 上要裁内容层,
// 会连标题栏三键与缩放热区一起裁掉(用户定案 2026-11-03)。
.with(SettingSpec::enumeration(
"appearance.window_corner",
"system",
super::appearance::WINDOW_CORNERS,
AiPolicy::audited(),
"窗口圆角:system(跟随系统:Win11/Server 2025 走 DWM,Win10 保持直角)/ square(总是直角),默认 system",
))
// 已废弃:D17 定案 appearance.theme_mode 为准(旧值读兼容映射,写永远走新键)。
.with(
SettingSpec::new(