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:
1 parent
aace0c5836
commit
88dbf6d955
29 files changed
+2047
-259
No files matched your search
@@ -0,0 +1,387 @@
|
||||
//! DWM 圆角(M116):把 [`CornerPlan`](super::super::corner::CornerPlan) 落成一次属性下发。
|
||||
//!
|
||||
//! ## 本文件零 `unsafe`、零 `extern`
|
||||
//!
|
||||
//! `DwmSetWindowAttribute` / `DwmGetWindowAttribute` 的声明与调用点在 [`super`]
|
||||
//! (全仓唯一 `unsafe` 落点);这里只做「哪一档 → 哪个属性值」的映射、`HRESULT` 解释与回读。
|
||||
//!
|
||||
//! ## 为什么「直角」也要真的下发一次
|
||||
//!
|
||||
//! `DWMWCP_DONOTROUND` 是**显式**的直角请求。不下发也能得到直角(Win10 就没有这个属性),
|
||||
//! 但那样日志里「方」与「没生效」长得一模一样。下发 + 回读之后,
|
||||
//! `corner=… source=square excluded_reason=user` 与 `excluded_reason=os-build` 是两条
|
||||
//! 可区分的事实,而不是一句猜测。
|
||||
//!
|
||||
//! ## 回读为什么是证据而不是自证
|
||||
//!
|
||||
//! `DwmSetWindowAttribute` 返回 `S_OK` 只说明「参数被接受了」。属性值由 DWM 保存,
|
||||
//! 回读拿到的是 **DWM 侧的当前值**——它能抓住「设了 A 属性、读回还是旧值」这类事
|
||||
//! (比如把 `DWMWA_WINDOW_CORNER_PREFERENCE` 写成别的号,回读会给出 `None` 或旧值)。
|
||||
|
||||
/// `DWMWA_WINDOW_CORNER_PREFERENCE`:窗口圆角偏好的属性号(Win11 / Server 2025,build ≥ 22000)。
|
||||
pub const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 34;
|
||||
/// `DWMWCP_DONOTROUND`:显式直角。
|
||||
pub const DWMWCP_DONOTROUND: u32 = 1;
|
||||
/// `DWMWCP_ROUND`:显式圆角(半径由系统决定,**不暴露**给应用)。
|
||||
pub const DWMWCP_ROUND: u32 = 2;
|
||||
/// `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS`:可见边框厚度(像素,Win11 起)。
|
||||
///
|
||||
/// 这是本机**唯一**能拿到的「DWM 到底把这个窗口当不当有框窗口」的数值信号:
|
||||
/// 圆角窗口带有一条 1px(按 DPI 缩放)的可见边框,直角窗口为 0。
|
||||
pub const DWMWA_VISIBLE_FRAME_BORDER_THICKNESS: u32 = 37;
|
||||
/// `DWMWA_EXTENDED_FRAME_BOUNDS`:DWM 认为的窗口边界(**含**不可见的调整带)。
|
||||
pub const DWMWA_EXTENDED_FRAME_BOUNDS: u32 = 9;
|
||||
|
||||
/// 一次圆角下发的结局(日志与测试共用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CornerOutcome {
|
||||
/// 非 Windows:空实现,什么都没做。
|
||||
UnsupportedPlatform,
|
||||
/// 句柄为 0:还没拿到窗口,或窗口已销毁。
|
||||
NoWindow,
|
||||
/// 已下发且回读一致。`readback == None` 表示**这一版 DWM 不接受回读**
|
||||
/// (属性不被认识时 `DwmGetWindowAttribute` 返回 `E_INVALIDARG`),此时只有 `S_OK` 作证。
|
||||
Applied {
|
||||
/// 下发下去的偏好值([`DWMWCP_ROUND`] / [`DWMWCP_DONOTROUND`])。
|
||||
preference: u32,
|
||||
/// DWM 侧回读到的值(`None` = 读不回来)。
|
||||
readback: Option<u32>,
|
||||
/// 回读那一次的 `HRESULT`(`0` = `S_OK`)。
|
||||
read_hresult: u32,
|
||||
},
|
||||
/// 属性不被这个 Windows 版本认识(`E_INVALIDARG`):Win10 就是这个结局。
|
||||
Unsupported {
|
||||
/// `DwmSetWindowAttribute` 的原始 `HRESULT`。
|
||||
hresult: u32,
|
||||
},
|
||||
/// 其他失败(句柄无效等),`hresult` 来自 DWM。
|
||||
Failed {
|
||||
/// `DwmSetWindowAttribute` 的原始 `HRESULT`。
|
||||
hresult: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl CornerOutcome {
|
||||
/// 一行日志尾巴:`corner=source=dwm pref=2 readback=2`。
|
||||
///
|
||||
/// `readback=?` 与 `pref=none`(平台不支持)是不同的坏消息:前者是「设了但读不回来」,
|
||||
/// 后者是「这里根本没有这个能力」。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Self::UnsupportedPlatform => "pref=none readback=n/a platform=unsupported".to_owned(),
|
||||
Self::NoWindow => "pref=none readback=n/a window=none".to_owned(),
|
||||
Self::Applied {
|
||||
preference,
|
||||
readback,
|
||||
read_hresult,
|
||||
} => format!(
|
||||
"pref={preference} readback={} read_hr=0x{read_hresult:08X}",
|
||||
readback.map_or_else(|| "?".to_owned(), |value| value.to_string())
|
||||
),
|
||||
Self::Unsupported { hresult } => {
|
||||
format!("pref=none readback=n/a hresult=0x{hresult:08X} (attribute unsupported)")
|
||||
}
|
||||
Self::Failed { hresult } => format!("pref=none readback=n/a hresult=0x{hresult:08X}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `HRESULT` 是不是 `E_INVALIDARG`(`0x8007_0057`):Win10 上设这个属性就是这个返回值。
|
||||
#[cfg(target_os = "windows")]
|
||||
const E_INVALIDARG: u32 = 0x8007_0057;
|
||||
|
||||
/// 把「来源」映射成要下发的偏好值(纯函数,测试直接钉它)。
|
||||
///
|
||||
/// `Dwm` → [`DWMWCP_ROUND`];`Square` → [`DWMWCP_DONOTROUND`]。
|
||||
/// 刻意**不用**默认值(`0`):那在 Win11 上等于「请 DWM 自己决定」,而无边框窗口
|
||||
/// 到底算不算「有框」由 DWM 的启发式决定——那正是我们要么拿到、要么明确放弃的东西。
|
||||
#[must_use]
|
||||
pub const fn preference_for(source: super::super::corner::CornerSource) -> u32 {
|
||||
use super::super::corner::CornerSource;
|
||||
match source {
|
||||
CornerSource::Dwm => DWMWCP_ROUND,
|
||||
CornerSource::Square => DWMWCP_DONOTROUND,
|
||||
}
|
||||
}
|
||||
|
||||
/// 下发一次圆角偏好(幂等:重复调用结果相同)。
|
||||
pub fn apply_corner(hwnd: isize, plan: &super::super::corner::CornerPlan) -> CornerOutcome {
|
||||
if hwnd == 0 {
|
||||
return CornerOutcome::NoWindow;
|
||||
}
|
||||
set_preference(hwnd, preference_for(plan.source))
|
||||
}
|
||||
|
||||
/// 下发一个具体的偏好值并回读(拆开是为了让探针能逐格遍历)。
|
||||
pub fn set_preference(hwnd: isize, preference: u32) -> CornerOutcome {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (hwnd, preference);
|
||||
return CornerOutcome::UnsupportedPlatform;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if hwnd == 0 {
|
||||
return CornerOutcome::NoWindow;
|
||||
}
|
||||
let hresult = super::dwm_set_u32(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, preference);
|
||||
if !super::hresult_ok(hresult) {
|
||||
// 原始位,不是 `unsigned_abs()`(见 `dwm_get_u32_hr` 的注释:后者印不出 HRESULT)。
|
||||
let code = hresult as u32;
|
||||
return if code == E_INVALIDARG {
|
||||
CornerOutcome::Unsupported { hresult: code }
|
||||
} else {
|
||||
CornerOutcome::Failed { hresult: code }
|
||||
};
|
||||
}
|
||||
let (readback, read_hresult) = corner_preference_hr(hwnd);
|
||||
CornerOutcome::Applied {
|
||||
preference,
|
||||
readback,
|
||||
read_hresult,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读圆角偏好**以及它的 `HRESULT`**(`(None, code)` = 读不回来,`code` 是 DWM 给的原因)。
|
||||
///
|
||||
/// 为什么要暴露 `HRESULT`:M116 本机实测——**写成功、读回 `E_INVALIDARG`**。只报 `?`
|
||||
/// 会让「设上了吗」变成猜谜;有了码就能说清是「属性不支持读」而不是「没设上」。
|
||||
#[must_use]
|
||||
pub fn corner_preference_hr(hwnd: isize) -> (Option<u32>, u32) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
super::dwm_get_u32_hr(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
(None, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 读可见边框厚度(像素)。这是本机唯一能拿到的「DWM 把窗口当不当有框窗口」的数值信号。
|
||||
#[must_use]
|
||||
pub fn visible_border_thickness(hwnd: isize) -> Option<u32> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
super::dwm_get_u32(hwnd, DWMWA_VISIBLE_FRAME_BORDER_THICKNESS)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 读 DWM 的窗口边界(`left, top, right, bottom`,屏幕坐标)。
|
||||
#[must_use]
|
||||
pub fn extended_frame_bounds(hwnd: isize) -> Option<(i32, i32, i32, i32)> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
super::dwm_get_rect(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS)
|
||||
.map(|rect| (rect.left, rect.top, rect.right, rect.bottom))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 读 `GetWindowRect`(含 DWM 加出来的不可见边框),用于和 [`extended_frame_bounds`] 求差。
|
||||
#[must_use]
|
||||
pub fn window_rect(hwnd: isize) -> Option<(i32, i32, i32, i32)> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
super::window_rect(hwnd).map(|rect| (rect.left, rect.top, rect.right, rect.bottom))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 读窗口样式位(`GWL_STYLE` = `-16`):证明「无边框 = `WS_POPUP`」这条前提。
|
||||
#[must_use]
|
||||
pub fn window_style(hwnd: isize) -> Option<isize> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
(hwnd != 0).then(|| super::window_style(hwnd))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 读窗口扩展样式位(`GWL_EXSTYLE`):配合 [`window_style`] 回答「我们是不是 `WS_EX_LAYERED`」。
|
||||
#[must_use]
|
||||
pub fn window_ex_style(hwnd: isize) -> Option<isize> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
(hwnd != 0).then(|| super::raw_ex_style(hwnd))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `WS_POPUP` 位:无边框窗口(winit 在 `decorations = false` 下给的形状)。
|
||||
pub const WS_POPUP: isize = 0x8000_0000_u32 as isize;
|
||||
|
||||
/// 帧样式的一句话标签(纯函数,日志用):`popup` / `overlapped` / `other`。
|
||||
///
|
||||
/// 为什么要它:用户的现象是「别的窗口有圆角,我们的没有」,而**我们被排除的第一嫌疑**就是
|
||||
/// `WS_POPUP`(无边框)。把它打进日志,事后才能把「这台机器的 DWM 对 popup 窗口不套圆角」
|
||||
/// 与「属性没设上」分开。
|
||||
#[must_use]
|
||||
pub const fn frame_label(style: isize) -> &'static str {
|
||||
const WS_OVERLAPPEDWINDOW_MASK: isize = 0x00CF_0000;
|
||||
if style & WS_POPUP != 0 {
|
||||
"popup"
|
||||
} else if style & WS_OVERLAPPEDWINDOW_MASK == WS_OVERLAPPEDWINDOW_MASK {
|
||||
"overlapped"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
/// 分层位的一句话标签(纯函数,日志用):`on` / `off`。
|
||||
#[must_use]
|
||||
pub const fn layered_label(ex_style: isize) -> &'static str {
|
||||
if ex_style & super::WS_EX_LAYERED != 0 {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个窗口的样式摘要(`frame=popup layered=on`);读不到时如实写 `frame=? layered=?`。
|
||||
#[must_use]
|
||||
pub fn style_summary(hwnd: isize) -> String {
|
||||
let frame = window_style(hwnd).map_or("?", frame_label);
|
||||
let layered = window_ex_style(hwnd).map_or("?", layered_label);
|
||||
format!("frame={frame} layered={layered}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
apply_corner, corner_preference_hr, preference_for, CornerOutcome,
|
||||
DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_DONOTROUND,
|
||||
DWMWCP_ROUND,
|
||||
};
|
||||
use crate::platform::win32::corner::{CornerPlan, CornerSource};
|
||||
use unlucky_presenter::prefs::WindowsVersion;
|
||||
|
||||
/// **抓得住**:属性号写错(34 写成 33 / 37 写成 36 —— 这两个都在 Win11 上存在,改错不会崩,
|
||||
/// 只会静默读出别的东西)、两个偏好值互换。
|
||||
/// **抓不住**:这些值在真窗口上是否真的生效(那要探针读数,见 `probe` 与报告)。
|
||||
#[test]
|
||||
fn attribute_numbers_follow_the_win32_header() {
|
||||
assert_eq!(DWMWA_WINDOW_CORNER_PREFERENCE, 34);
|
||||
assert_eq!(DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, 37);
|
||||
assert_eq!(DWMWCP_ROUND, 2);
|
||||
assert_eq!(DWMWCP_DONOTROUND, 1);
|
||||
}
|
||||
|
||||
/// **抓得住**:`preference_for` 把圆角/直角映射反了(这一格反了就是「设置里选直角反而更圆」)。
|
||||
#[test]
|
||||
fn the_preference_mapping_cannot_be_swapped() {
|
||||
assert_eq!(preference_for(CornerSource::Dwm), DWMWCP_ROUND);
|
||||
assert_eq!(preference_for(CornerSource::Square), DWMWCP_DONOTROUND);
|
||||
}
|
||||
|
||||
/// 无效句柄:**不下发**、也不 panic。句柄 0 走 `NoWindow`(还没拿到窗口 ≠ 平台不支持)。
|
||||
#[test]
|
||||
fn a_missing_window_is_reported_not_guessed() {
|
||||
let plan = CornerPlan {
|
||||
source: CornerSource::Dwm,
|
||||
excluded: None,
|
||||
os: Some(WindowsVersion::new(10, 0, 26_100)),
|
||||
};
|
||||
assert_eq!(apply_corner(0, &plan), CornerOutcome::NoWindow);
|
||||
assert_eq!(corner_preference_hr(0).0, None);
|
||||
// 1 号句柄永远无效:非 Windows 上是「平台不支持」,Windows 上是 DWM 的失败码,
|
||||
// 两种都不许表现成「成功」。
|
||||
let outcome = apply_corner(1, &plan);
|
||||
assert!(
|
||||
matches!(
|
||||
outcome,
|
||||
CornerOutcome::Failed { .. }
|
||||
| CornerOutcome::Unsupported { .. }
|
||||
| CornerOutcome::UnsupportedPlatform
|
||||
),
|
||||
"无效句柄应报失败,实际 {outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 日志尾巴三态可区分:设了 / 设了但读不回 / 属性不支持。
|
||||
#[test]
|
||||
fn the_log_tail_separates_applied_from_unsupported() {
|
||||
let applied = CornerOutcome::Applied {
|
||||
preference: DWMWCP_ROUND,
|
||||
readback: Some(DWMWCP_ROUND),
|
||||
read_hresult: 0,
|
||||
};
|
||||
assert!(
|
||||
applied.describe().starts_with("pref=2 readback=2"),
|
||||
"{applied:?}"
|
||||
);
|
||||
|
||||
let blind = CornerOutcome::Applied {
|
||||
preference: DWMWCP_ROUND,
|
||||
readback: None,
|
||||
read_hresult: 0x8000_4005,
|
||||
};
|
||||
assert!(
|
||||
blind.describe().contains("readback=?"),
|
||||
"{}",
|
||||
blind.describe()
|
||||
);
|
||||
|
||||
let old = CornerOutcome::Unsupported {
|
||||
hresult: 0x8007_0057,
|
||||
};
|
||||
assert!(
|
||||
old.describe().contains("attribute unsupported"),
|
||||
"{}",
|
||||
old.describe()
|
||||
);
|
||||
assert!(CornerOutcome::UnsupportedPlatform
|
||||
.describe()
|
||||
.contains("platform=unsupported"));
|
||||
}
|
||||
}
|
||||
|
||||
/// **抓得住**:`frame_label` 把 `WS_POPUP` 与 `WS_OVERLAPPEDWINDOW` 判反(那会让日志把
|
||||
/// 「我们自己的窗口」写成「普通窗口」,这行日志的全部价值就是分辨这个)。
|
||||
/// **抓不住**:真窗口上到底置了哪一位(那属于运行时读数)。
|
||||
#[test]
|
||||
fn the_frame_label_separates_popup_from_a_decorated_window() {
|
||||
assert_eq!(WS_POPUP, 0x8000_0000_u32 as isize);
|
||||
assert_eq!(frame_label(WS_POPUP | 0x1000_0000), "popup");
|
||||
assert_eq!(frame_label(0x00CF_0000), "overlapped");
|
||||
assert_eq!(frame_label(0), "other");
|
||||
}
|
||||
|
||||
/// 分层位标签:只认 `WS_EX_LAYERED`,别把 `WS_EX_TRANSPARENT` 之类的邻居混进来。
|
||||
#[test]
|
||||
fn the_layered_label_reads_the_right_bit() {
|
||||
let layered = crate::platform::win32::transparency::WS_EX_LAYERED;
|
||||
assert_eq!(layered_label(layered), "on");
|
||||
assert_eq!(layered_label(WS_POPUP), "off");
|
||||
assert_eq!(layered_label(0), "off");
|
||||
}
|
||||
|
||||
/// 读不到句柄时摘要不许编:`frame=? layered=?`(「不知道」与「没有」是两件事)。
|
||||
#[test]
|
||||
fn the_style_summary_never_guesses() {
|
||||
assert_eq!(style_summary(0), "frame=? layered=?");
|
||||
}
|
||||
Reference in new issue
Block a user