Files
Unlucky-Launcher/crates/app-iced/src/platform/win32/transparency/dwm.rs
T
AskaEth 0451b3f972 fix(m182): 非 Windows 圆角判据回答「由谁负责」,并给 macOS 直角三条源码级证据
用户第三轮 Mac 验收看到四角直角 + `source=square os=unknown`。原来非 Windows 一律
落到 `UnknownOs`,把「Windows 版本读不出来」与「这台根本不是 Windows」糊成同一格。

- `platform/host.rs`:新增 `HostOs`——平台族是一个**值**而不是散落的 `cfg`。
  这样三行真值表能在本机(只有 Windows)一次验完,`cfg` 只留在真要 FFI 的地方。
- `plan_corner` 拆成 `plan_corner_for(policy, host, os, backend)` + 保持原签名的薄包装
  (`app/update/appearance.rs` 在 scope 外,调用点一字未改)。
- `CornerSource` 加第三档 `Compositor`(跟随合成器,我们既不下发也不自绘);
  `CornerExclusion` 加 `borderless`(macOS)、`client-side`(Linux)、`unsupported`。
- 日志加 `host=`,非 Windows 的 `os=` 由 `unknown` 改 `n/a`(那栏是 Windows 版本,
  在 macOS/Linux 上不是「没读到」而是「跟本机无关」)。
- `platform/macos/mod.rs`:macOS 为什么是直角的**三环证据**(我们的 `decorations: false`
  → winit `Borderless`(无 `Titled`)→ 窗口服务器只裁 titled 窗;winit macOS 后端
  `cornerRadius` 命中 0),以及「迁移原生标题栏」的未实现配方与阻塞点(iced 0.14 未转发
  winit 的 `titlebar_buttons_hidden`,缩放键按不掉)。
- `platform/linux/mod.rs`:xdg-shell 无圆角概念、wl_surface 区域只能是矩形、XShape 只能
  切硬边 ⇒ CSD 窗没人替我们裁,D46 排除自绘 ⇒ 直角。没把 DWM 判据搬过去(单测钉着
  「非 Windows 不许出现 dwm」)。
- `design/shape.rs`:删掉「macOS 走 `layer.cornerRadius`」这句过期描述(D46 不许自绘裁切)。
- `dwm.rs` 的内联测试挪到 `dwm/tests.rs`(原文件 398 行,加第三档映射会顶破 400 行红线)。

验证:cargo fmt --check / clippy -D warnings / `cargo test -p unlucky-app-iced --lib`
684 passed;三次定向变异各自变红(macOS 行改回 Square/UnknownOs → 4 红;非 Windows
`os=` 改回 unknown → 1 红;Linux 原因码复制成 borderless → 4 红)。
Windows 侧无语义改动;`cargo test --lib m116 -- --ignored --nocapture` 复跑读回仍是
`pref=2 readback=? read_hr=0x80070057`(写入被接受、回读不可用),**不作为「圆角已生效」的证据**。
2026-11-08 14:50:15 +08:00

317 lines
13 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.
//! 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` 或旧值)。
// M147:本模块七项(三个属性号 + 四个读函数)只在 Windows 上有使用者——唯一调用方 `probe::run_one`
// 的 Windows 版被门着,Linux + `-D warnings` 下就是 dead_code 错。用模块级 `cfg_attr` 而不是
// 逐项 `cfg`:`cfg` 会把这些项连同本文件那几条与平台无关的单测一起门掉(逐项 `cfg_attr` 多 7 行,顶破红线)。
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
/// `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 起)。
///
/// ⚠️ **本机实测它不能当「圆不圆」的判据**(我最初以为能,被探针否掉了):
/// 三种窗口样式 × 三种状态(默认 / `DWMWCP_ROUND` / `DWMWCP_DONOTROUND`)读出来**全是 `1`**,
/// 没有任何区分度。保留它只作诊断读数(万一将来在别的机器上有变化),不参与判定。
/// 证据:`DevDocs/artifacts/m116/probe-table.md` 的 `bdef` / `brnd` / `bsq` 三列。
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 的启发式决定——那正是我们要么拿到、要么明确放弃的东西。
///
/// M182 的 `Compositor` 档(跟随合成器)在 Windows 上**不可达**(`plan_corner_for` 不产出它,
/// 单测钉着这一点);真要出现,正确的落点是默认值 `0`(= 让系统自己决定),
/// 而不是被当成「强制直角」——否则 `source=compositor` 那行日志会与下发值自相矛盾。
#[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::Compositor => 0,
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);
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)
}
}
/// 读可见边框厚度(像素)。**诊断用,不可作判据**(本机三样式三态实测全是 1,见常量文档)。
#[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`):日志里 `frame=` 那一栏的取值来源。
///
/// ⚠️ 产品窗口**不是** `WS_POPUP`(M116 r4 复审订正):winit 在 `decorations = false` 下给的是
/// `WS_CAPTION|WS_BORDER|...`(见 `probe::PRODUCT_STYLE`),真进程的 `frame=` 应为 `overlapped`
/// (关掉 min/max 则 `other`)。「无边框」是 `WM_NCCALCSIZE` 的效果,不是样式位。
#[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` 位(`0x8000_0000`):**带 owner 的弹出窗**才置它;产品主窗口不用这一位。
pub const WS_POPUP: isize = 0x8000_0000_u32 as isize;
/// 帧样式的一句话标签(纯函数,日志用):`popup` / `overlapped` / `other`。
///
/// 为什么要它:用户的现象是「别的窗口有圆角,我们的没有」。把它打进日志,才能一眼看出
/// 窗口形状这一项**不是**变量(产品恒为 `overlapped`)——真正让系统忽略圆角请求的是整窗 α
/// 引入的 `WS_EX_LAYERED`(见 `layered=` 与 `suppressed_by=window_alpha`),不是 `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=overlapped layered=off`,产品真进程实测值);读不到时如实写
/// `frame=? layered=?`。「`frame=popup`」只会出现在**带 owner 的弹出窗**上,产品不会。
#[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;
/// **抓得住**:`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=?");
}