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

+10 -1
View File
@@ -66,7 +66,12 @@ impl App {
match change.key() {
// 两个只读下层键:走 schema 指令(`SettingsBridge` 的结构里没有它们)。
Some(_) if matches!(change, Change::WindowOpacity(_) | Change::PaletteSource(_)) => {
Some(_)
if matches!(
change,
Change::WindowOpacity(_) | Change::PaletteSource(_) | Change::Corner(_)
) =>
{
if let (Some(key), Some(value)) = (change.key(), change.value()) {
self.dispatch(AppCommand::SetSetting {
key: key.to_owned(),
@@ -84,6 +89,8 @@ impl App {
} else {
self.log_appearance("change", None);
}
// M116:圆角档位变了要**重新判定 + 重新下发**(与整窗 α 是两条独立的管线)。
self.apply_window_corner("change");
}
/// 重读配置后同步外观(热切:外部 CLI / AI 改完 1 秒内生效)。
@@ -95,6 +102,8 @@ impl App {
self.rebuild_ui();
self.view_dirty = true;
self.apply_window_alpha("reload");
// M116:外部(CLI / AI)改了 `appearance.window_corner` 也要热生效。
self.apply_window_corner("reload");
}
/// 把外观写回 `launcher.json`(只改本 mission 管的字段,强调色/玻璃原样保留)。
+4
View File
@@ -44,6 +44,7 @@ use super::CAPTURE_AT_FRAME;
mod account;
mod agent;
mod appearance;
mod boot_env;
mod content;
mod download;
@@ -75,6 +76,9 @@ impl App {
self.log_appearance("boot", None);
}
}
// M116:圆角放在 α **之后**——α < 1 时那一步才补上 `WS_EX_LAYERED`,
// 先打圆角日志会把 `layered=` 记成 `off`(那正是我们要分辨的那一位)。
self.apply_window_corner("boot");
Task::none()
}
Message::SystemTheme(mode) => {
@@ -0,0 +1,56 @@
//! M116:窗口圆角的下发落点(`impl App::apply_window_corner`)。
//!
//! ## 为什么单独成文件
//!
//! 圆角与整窗 α 是**两条独立的管线**,只是都必须拿到 HWND、都在同一个时点下发:
//!
//! | 管线 | 判据 | 落点 | 键 |
//! |---|---|---|---|
//! | 整窗 α(D36) | `platform::effective_window_alpha` | `SetLayeredWindowAttributes` | `appearance.window_opacity` |
//! | 圆角(M116) | `platform::plan_corner` | `DwmSetWindowAttribute` | `appearance.window_corner` |
//!
//! 合在一个函数里会让「α 变了要不要重设圆角」这种问题变成实现细节;分开之后两个判据各自可测。
//!
//! ## 日志为什么是**一行、六栏**
//!
//! `corner=policy=… source=dwm|square os=… excluded_reason=… radius=system pref=… readback=… frame=… layered=…`
//!
//! 用户报的现象是「别的窗口都有圆角,只有我们的没有」。没有这一行,事后无法区分
//! 「没生效」「生效了但看不见」「这台系统根本没有这个能力」;`frame` / `layered` 两栏则是
//! 「我们被 DWM 排除」的第一嫌疑(`WS_POPUP` 无边框、`WS_EX_LAYERED` 整窗 α)的现场读数。
//!
//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
//! (M116 取消了 8/12/16 固定档)。
use crate::platform;
use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome};
use super::super::state::App;
impl App {
/// 把当前圆角策略下发到窗口,并打一行可分辨的日志。
///
/// 没有句柄时**也打日志**(`window=none`):不能让「没生效」只体现为屏幕上没变化。
pub(crate) fn apply_window_corner(&self, context: &str) {
let policy = self.appearance.window_corner();
let plan = platform::plan_corner(
policy,
platform::current_windows_version(),
self.appearance.backend,
);
// 行的形状只有一处(`corner::format_line`),因为那里能在没有窗口的环境里被单测钉住。
// 「我们被 DWM 排除」的第一嫌疑是无边框(`WS_POPUP`)与整窗 α(`WS_EX_LAYERED`),
// 所以 `frame=` / `layered=` 必须和圆角结论在同一行——否则以后只能猜是哪一条。
let (outcome, style) = match self.hwnd {
Some(hwnd) => (apply_corner(hwnd, &plan).describe(), style_summary(hwnd)),
None => (
CornerOutcome::NoWindow.describe(),
"frame=? layered=?".to_owned(),
),
};
eprintln!(
"{}",
platform::win32::corner::format_line(context, &plan, policy, &outcome, &style)
);
}
}
@@ -80,6 +80,8 @@ pub enum Change {
BgDim(f32),
/// 配色来源档。
PaletteSource(unlucky_presenter::prefs::PaletteSource),
/// 窗口圆角档位(M116:跟随系统 / 直角)。
Corner(unlucky_presenter::prefs::WindowCorner),
/// 设置页切分组(左列点选)。
SelectGroup(usize),
}
@@ -96,6 +98,7 @@ impl Change {
Self::BgFill(_) => unlucky_presenter::prefs::keys::BG_FILL,
Self::BgDim(_) => unlucky_presenter::prefs::keys::BG_DIM,
Self::PaletteSource(_) => unlucky_presenter::prefs::keys::PALETTE_SOURCE,
Self::Corner(_) => unlucky_presenter::prefs::keys::WINDOW_CORNER,
Self::SelectGroup(_) => return None,
})
}
@@ -113,6 +116,7 @@ impl Change {
Self::BgFill(fill) => fill.as_str().to_owned(),
Self::BgDim(dim) => format!("{dim}"),
Self::PaletteSource(source) => source.as_str().to_owned(),
Self::Corner(corner) => corner.as_str().to_owned(),
Self::SelectGroup(_) => return None,
})
}
@@ -139,6 +143,10 @@ mod tests {
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:?}");
@@ -5,6 +5,7 @@
//! | 控件 | 键 | 改动消息 |
//! |---|---|---|
//! | 整窗透明度滑块(下限 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`] |
@@ -30,7 +31,8 @@ use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_presenter::prefs::{BgFill, PaletteSource};
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};
@@ -66,7 +68,35 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
),
));
// ② 面板透明度(CPU 档强制 1.0)
// ② 窗口圆角(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),
),
],
));
rows = rows.push(note_line(
ui,
t(Key::SettingsAppearanceCornerHint),
ui.color(Role::OnSurfaceVariant),
));
// ③ 面板透明度(CPU 档强制 1.0)
let forced = state.panel_is_forced();
let panel_percent = if forced {
format!("100% (forced from {:.2})", state.panel_alpha)
@@ -96,7 +126,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
panel_control,
));
// ③ 背景图开关
// ④ 背景图开关
rows = rows.push(choice_row(
ui,
"Background image",
@@ -106,7 +136,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
],
));
// ④ 路径(文本框:受控,输入即生效)
// ⑤ 路径(文本框:受控,输入即生效)
let path_field: Element<'a, Message> = text_field(
ui,
Field::new(&state.bg_path)
@@ -120,7 +150,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
.width(Length::Fill),
);
// ⑤ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
// ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
for note in state.diagnostics() {
rows = rows.push(note_line(ui, note, ui.color(Role::Error)));
@@ -140,7 +170,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
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",
@@ -166,7 +196,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
],
));
// ⑦ 暗化
// ⑧ 暗化
rows = rows.push(slider_row(
ui,
"Dim",
@@ -181,7 +211,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
),
));
// ⑧ 配色来源
// ⑨ 配色来源
rows = rows.push(choice_row(
ui,
"Colour source",
@@ -238,6 +268,15 @@ fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
)
}
/// 设置页文案的 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)
+24 -2
View File
@@ -17,7 +17,9 @@
//! 变成一条**可读诊断**而不是一个 `unwrap`。
use unlucky_design::color::{Scheme, SchemeFlavor};
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs, BgFill, PaletteSource};
use unlucky_presenter::prefs::{
AppearanceBase, AppearancePrefs, BgFill, PaletteSource, WindowCorner,
};
use crate::Backend;
@@ -57,6 +59,8 @@ pub struct State {
pub palette_source: PaletteSource,
/// 玻璃拟态开关(CPU 档强制关)。
pub glass: bool,
/// 窗口圆角策略(M116:`system` / `square`;真能不能圆由平台判,见 `platform::plan_corner`)。
pub window_corner: WindowCorner,
/// 渲染后端(决定 CPU 档的两条降级)。
pub backend: Backend,
/// 设置页当前分组(0 = General … 1 = Appearance;见 [`super::APPEARANCE_GROUP`])。
@@ -88,6 +92,7 @@ impl State {
bg_fill: prefs.bg_fill,
bg_dim: prefs.bg_dim,
palette_source: base.palette_source,
window_corner: base.window_corner,
glass: prefs.glass,
backend,
selected_group: super::APPEARANCE_GROUP,
@@ -142,6 +147,17 @@ impl State {
panel_is_forced_opaque(self.backend)
}
/// 当前圆角策略(设置页显示选中项、日志与下发都用它)。
#[must_use]
pub fn window_corner(&self) -> WindowCorner {
self.window_corner
}
/// 改圆角策略(消息落点见 `app::update::appearance`;这里只管状态,不碰窗口)。
pub fn set_window_corner(&mut self, policy: WindowCorner) {
self.window_corner = policy;
}
/// 玻璃是否生效(CPU 档恒关)。
#[must_use]
pub fn effective_glass(&self) -> bool {
@@ -263,6 +279,10 @@ impl State {
self.refresh_palette();
false
}
Change::Corner(policy) => {
self.window_corner = policy;
false
}
Change::SelectGroup(index) => {
self.selected_group = index;
false
@@ -296,10 +316,11 @@ impl State {
.map_or_else(|| "none".to_owned(), |byte| byte.to_string());
format!(
"[appearance] {context} window_alpha={:.3} panel_alpha={:.3} (requested {:.3}) \
byte={byte} backend={} bg={} fill={} dim={:.2} palette={} group={}",
byte={byte} corner={} backend={} bg={} fill={} dim={:.2} palette={} group={}",
self.window_opacity,
self.effective_panel_alpha(),
self.panel_alpha,
self.window_corner.as_str(),
self.backend.name(),
if self.background_active() {
self.bg_path.as_str()
@@ -317,6 +338,7 @@ impl State {
pub fn reload(&mut self, prefs: &AppearancePrefs, base: &AppearanceBase) {
self.window_opacity = base.window_opacity;
self.palette_source = base.palette_source;
self.window_corner = base.window_corner;
self.panel_alpha = prefs.panel_alpha;
self.bg_enabled = prefs.bg_image_enabled;
self.bg_fill = prefs.bg_fill;
@@ -25,7 +25,7 @@ pub(super) const GROUP: Group = Group {
icon: IconKind::Sidebar,
rows: &[
("Density", "Comfortable"),
("Corner style", "Rounded"),
("Corner style", "Follow system"),
("Motion", "Full"),
("Text scale", "100%"),
],
@@ -21,7 +21,7 @@ fn the_appearance_group_shows_density_corner_motion_and_text_scale() {
rows,
vec![
("Density", "Comfortable"),
("Corner style", "Rounded"),
("Corner style", "Follow system"),
("Motion", "Full"),
("Text scale", "100%"),
]
+25 -3
View File
@@ -1,8 +1,12 @@
//! 平台相关的窗口能力(P4 / D36):整窗透明度的**判据**与唯一 `unsafe` 落点分居两处。
//! 平台相关的窗口能力(P4 / D36 / M116):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` 落点,
//! 以及系统语言真值源,三件事分居三处。
//!
//! - 本文件 = 纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(补 `WS_EX_LAYERED` + `SetLayeredWindowAttributes`)。
//! - [`win32::corner`] = 圆角的纯判据 [`plan_corner`](M116):用户策略 × 系统版本 × 渲染档 →
//! 来源与原因码。两个判据都**不碰** FFI,因此都能被断言证明;
//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(补 `WS_EX_LAYERED` +
//! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`);
//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。
//!
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
@@ -17,6 +21,24 @@ use crate::Backend;
pub mod locale;
pub mod win32;
/// 窗口圆角的**判定**(M116)住在 `win32::corner`:判据是 Windows 版本与 DWM 能力,
/// 非 Windows 平台在这里恒得「直角」(平台没有可承诺的系统圆角)。
pub use win32::corner::{plan_corner, CornerExclusion, CornerPlan, CornerSource};
/// 本机 Windows 版本(`None` = 非 Windows / 读不到)。**进程内只探测一次**。
///
/// 为什么缓存:底层是 `reg.exe` 子进程(`unlucky_core::platform::windows_version`,实测约 50 ms),
/// 而圆角判定会在启动、切档、热重载时各问一次。探测结果在进程生命周期内不会变。
///
/// 为什么走 `unlucky_presenter::prefs` 的重导出而不是 `unlucky_core` 直连:
/// `unlucky-app-iced` 不直接依赖 core(分层:UI → presenter → core),这是既有的唯一通道。
#[must_use]
pub fn current_windows_version() -> Option<unlucky_presenter::prefs::WindowsVersion> {
static CACHED: std::sync::OnceLock<Option<unlucky_presenter::prefs::WindowsVersion>> =
std::sync::OnceLock::new();
*CACHED.get_or_init(unlucky_presenter::prefs::probe_windows_version)
}
/// 用户请求的整窗 α → 真正写进窗口的值(纯函数)。
///
/// - `requested <= 0.0`(含 `-0.0`)→ `None`:**拒绝整窗穿透**。α = 0 表示窗口内容全透明,
@@ -0,0 +1,414 @@
//! M116:窗口外框圆角的**判据**(纯函数,零 `unsafe`、零 FFI、零环境读取)。
//!
//! ## 判据为什么单独拎出来
//!
//! 「能不能圆」= 用户策略 × 系统版本 × 渲染档,这三件事**可以被断言证明**;而 DWM 的**视觉**
//! 结果在本仓库的验证环境(RDP 断连、抓屏失效)上没有参考价值。把可测的那一半独立出来,
//! 才有「有测试的判据 + 没测试的观感」这条诚实的分界(与 [`super::super::effective_window_alpha`]
//! 同一套分法)。
//!
//! 真正下发给窗口的动作在 [`super::transparency`](全仓唯一 `unsafe` 落点)。
//!
//! ## 平台矩阵(真值表的出处)
//!
//! | 平台 | 系统圆角 | 我们做什么 |
//! |---|---|---|
//! | Win11 / Server 2025(build ≥ 22000) | 有(DWM 合成器画,抗锯齿) | `DWMWCP_ROUND` |
//! | Win10 及更早(build < 22000) | 无(该属性不存在) | 直角;不做 `SetWindowRgn` 毛边默认 |
//! | macOS | 有(`layer.cornerRadius`,系统/合成器决定) | **不主动加**,交给系统 |
//! | Linux X11 / Wayland | 看合成器(客户端管不了外框) | **不承诺**,直角即如实结果 |
//!
//! 后两行在本 crate 里落到同一格:探测不到 Windows 版本 ⇒ `UnknownOs` ⇒ 直角。
//! 这是「不承诺」,不是「做不到」——两者的区别写在日志的原因码里。
use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD};
use crate::Backend;
/// 一次「圆角该从哪来」的判定结果(M116)。
///
/// 只有两种来源,且**互相排斥**:
///
/// | 来源 | 谁画圆角 | 抗锯齿 | 我们做什么 |
/// |---|---|---|---|
/// | [`CornerSource::Dwm`] | DWM 合成器 | 有(系统画) | 只下发 `DWMWA_WINDOW_CORNER_PREFERENCE` |
/// | [`CornerSource::Square`] | 没人 | — | 明确下发 `DWMWCP_DONOTROUND`,把「方」也变成可观测的选择 |
///
/// **没有 `Self`(自绘圆角)这一档**,这是刻意的(用户定案 2026-11-03 + tower 裁定):
/// 窗口是 `decorations=false` + 自绘标题栏 + 整窗 `LWA_ALPHA`,自绘圆角意味着把内容层裁成圆角,
/// 那会把标题栏三键与边缘缩放热区一起裁掉;系统也不暴露圆角半径(无 API 可读),
/// 「固定 8/12/16」只是凭空维护一个数字。所以非 DWM 平台**就是直角**,并且日志里说明原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CornerSource {
/// DWM 系统圆角(Win11 / Server 2025 内核,build ≥ [`WIN11_MIN_BUILD`])。
Dwm,
/// 直角(用户选择、系统不支持、或该平台不承诺)。
Square,
}
impl CornerSource {
/// 日志取值(`corner=… source=dwm|square`)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Dwm => "dwm",
Self::Square => "square",
}
}
/// 是否要求 DWM 真去画圆角。
#[must_use]
pub const fn is_dwm(self) -> bool {
matches!(self, Self::Dwm)
}
}
/// 为什么**不是** DWM 圆角(`None` = 就是 DWM 圆角)。日志里逐字打出来。
///
/// 为什么要这个枚举而不是一个 `bool`:用户报的现象是「别的窗口都有圆角,只有我们的没有」,
/// 于是「没圆角」至少有四种完全不同的原因,混成一个 `square` 以后没人能分辨
/// 「没生效」与「生效了但看不见」(M116 任务书原话)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CornerExclusion {
/// 用户在设置里选了「直角」。
UserSquare,
/// 系统太老:Win10 及更早(build < [`WIN11_MIN_BUILD`])——DWM 没有这个偏好属性。
OsBuild,
/// 版本探测不出来(非 Windows / 注册表读不到 / 有东西在骗版本号)。
UnknownOs,
/// 非 DWM 平台上的软件渲染档:圆角只能靠自绘/`SetWindowRgn` 毛边路,D36 的
/// 「CPU 档不碰合成技巧」优先,所以退回直角。
CpuTier,
}
impl CornerExclusion {
/// 日志取值(`excluded_reason=…`)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::UserSquare => "user",
Self::OsBuild => "os-build",
Self::UnknownOs => "unknown-os",
Self::CpuTier => "cpu-tier",
}
}
}
/// 判定结果:来源 + 排除原因 + 平台版本(日志与测试共用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CornerPlan {
/// 圆角从哪来。
pub source: CornerSource,
/// `None` = 走 DWM;`Some(..)` = 走直角,并说明为什么。
pub excluded: Option<CornerExclusion>,
/// 探测到的 Windows 版本(`None` = 不是 Windows / 读不到)。
pub os: Option<WindowsVersion>,
}
impl CornerPlan {
/// 日志行:`corner=policy=system source=dwm os=10.0.26100 excluded_reason=none radius=system`。
///
/// **`radius=system` 是刻意的**:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
/// (用户定案取消了 8/12/16 档),所以这一栏永远只说「系统决定」。
#[must_use]
pub fn log_line(&self, policy: WindowCorner) -> String {
let os = self
.os
.map_or_else(|| "unknown".to_owned(), |os| os.version_string());
format!(
"corner=policy={} source={} os={os} excluded_reason={} radius=system",
policy.as_str(),
self.source.as_str(),
self.excluded.map_or("none", CornerExclusion::as_str),
)
}
}
/// 判定「这一次窗口该不该有系统圆角」(纯函数,不调 FFI、不读环境)。
///
/// 真值表(`backend` 只影响**非 DWM** 平台,见下):
///
/// | `policy` | `os.build` | backend | `source` | `excluded` |
/// |---|---|---|---|---|
/// | `Square` | 任意 | 任意 | `Square` | `UserSquare` |
/// | `System` | `None` | 任意 | `Square` | `UnknownOs` |
/// | `System` | `< 22000` | GPU/Auto | `Square` | `OsBuild` |
/// | `System` | `< 22000` | CPU | `Square` | `CpuTier` |
/// | `System` | `>= 22000` | 任意 | `Dwm` | `None` |
///
/// 为什么 CPU 档在 Win11 上**照样**用 DWM 圆角:D36 限制的是**我们**做的合成技巧(面板 α /
/// 玻璃 / 模糊),而 DWM 圆角是系统合成器自己的行为,不消耗我们的渲染预算,也不违反
/// 「CPU 档不碰透明叠加」——把 Win11 的圆角在 CPU 档砍掉等于凭空造一个没人要的降级。
/// 反过来在非 DWM 平台上,圆角只剩「自绘裁切 / `SetWindowRgn` 毛边」这两条路,那才是 D36
/// 要拦的东西,所以那一格落 `cpu-tier` 而不是 `os-build`(同一个直角,**原因不同**)。
#[must_use]
pub fn plan_corner(
policy: WindowCorner,
os: Option<WindowsVersion>,
backend: Backend,
) -> CornerPlan {
let square = |excluded| CornerPlan {
source: CornerSource::Square,
excluded: Some(excluded),
os,
};
if policy == WindowCorner::Square {
return square(CornerExclusion::UserSquare);
}
match os {
None => square(CornerExclusion::UnknownOs),
Some(os) if os.build < WIN11_MIN_BUILD => match backend {
Backend::Cpu => square(CornerExclusion::CpuTier),
Backend::Gpu | Backend::Auto => square(CornerExclusion::OsBuild),
},
Some(_) => CornerPlan {
source: CornerSource::Dwm,
excluded: None,
os,
},
}
}
/// 产品日志行的**唯一格式化落点**(纯函数:把四段文本拼成一个固定形状)。
///
/// ```text
/// [corner] <context> corner=policy=system source=dwm os=10.0.26100 excluded_reason=none /// radius=system pref=2 readback=? read_hr=0x80070057 frame=popup layered=on
/// ```
///
/// 为什么单独拎出来:`App::apply_window_corner` 要真句柄、真 DWM 才能跑,而**这一行的形状**
/// 恰恰是「以后分辨没生效 / 生效了看不见」的唯一依据(M116 任务书原话)。形状必须在没有窗口的
/// 环境里也能被断言——tower 2026-11-03 明确要求:不为了这行日志去构建 bin。
///
/// 参数里 `outcome` / `style` 由调用方给(`CornerOutcome::describe()` / `style_summary(hwnd)`),
/// 本文件不依赖 FFI 层,因此可以在非 Windows 目标上编、也能被单测覆盖。
#[must_use]
pub fn format_line(
context: &str,
plan: &CornerPlan,
policy: WindowCorner,
outcome: &str,
style: &str,
) -> String {
format!(
"[corner] {context} {} {outcome} {style}",
plan.log_line(policy)
)
}
#[cfg(test)]
mod corner_tests {
use super::{plan_corner, CornerExclusion, CornerPlan, CornerSource};
use crate::Backend;
use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD};
/// 本机实测版本(Server 2025 / build 26100)。
fn win11() -> WindowsVersion {
WindowsVersion::new(10, 0, 26_100)
}
/// Win10 22H2(build 19045):DWM 没有圆角偏好属性。
fn win10() -> WindowsVersion {
WindowsVersion::new(10, 0, 19_045)
}
/// **抓得住**:判定链的顺序与每一格的原因码(把 `policy`/`os`/`backend` 任一条判反都会红)。
/// **抓不住**:DWM 到底有没有真画圆角(那要像素,见 `platform/win32` 的探针输出)。
#[test]
fn truth_table_policy_os_backend() {
let cases = [
// policy=Square:任何系统/后端都直角,原因是用户选的。
(
WindowCorner::Square,
Some(win11()),
Backend::Gpu,
CornerSource::Square,
Some(CornerExclusion::UserSquare),
),
(
WindowCorner::Square,
Some(win10()),
Backend::Cpu,
CornerSource::Square,
Some(CornerExclusion::UserSquare),
),
(
WindowCorner::Square,
None,
Backend::Auto,
CornerSource::Square,
Some(CornerExclusion::UserSquare),
),
// policy=System + 探测不出系统:不许猜「Win11」。
(
WindowCorner::System,
None,
Backend::Gpu,
CornerSource::Square,
Some(CornerExclusion::UnknownOs),
),
// Win10:直角,GPU 档原因是版本、CPU 档原因是渲染档(同一个直角,原因不同)。
(
WindowCorner::System,
Some(win10()),
Backend::Gpu,
CornerSource::Square,
Some(CornerExclusion::OsBuild),
),
(
WindowCorner::System,
Some(win10()),
Backend::Auto,
CornerSource::Square,
Some(CornerExclusion::OsBuild),
),
(
WindowCorner::System,
Some(win10()),
Backend::Cpu,
CornerSource::Square,
Some(CornerExclusion::CpuTier),
),
// Win11 / Server 2025:三个后端都用 DWM(D36 只管我们自己的合成技巧)。
(
WindowCorner::System,
Some(win11()),
Backend::Gpu,
CornerSource::Dwm,
None,
),
(
WindowCorner::System,
Some(win11()),
Backend::Cpu,
CornerSource::Dwm,
None,
),
(
WindowCorner::System,
Some(win11()),
Backend::Auto,
CornerSource::Dwm,
None,
),
];
for (policy, os, backend, want_source, want_excluded) in cases {
let plan = plan_corner(policy, os, backend);
assert_eq!(
plan.source, want_source,
"policy={policy:?} os={os:?} backend={backend:?}"
);
assert_eq!(
plan.excluded, want_excluded,
"policy={policy:?} os={os:?} backend={backend:?} 的原因码"
);
}
}
/// 边界:`WIN11_MIN_BUILD` **本身**算 Win11(`>=`,不是 `>`)。
///
/// 抓得住:把 `build < WIN11_MIN_BUILD` 写成 `<=`——Win11 21H2(build 22000)会静默掉成直角,
/// 而那正好是第一台 Win11 机器。
#[test]
fn the_win11_boundary_is_inclusive() {
let at = WindowsVersion::new(10, 0, WIN11_MIN_BUILD);
assert_eq!(
plan_corner(WindowCorner::System, Some(at), Backend::Gpu).source,
CornerSource::Dwm
);
let below = WindowsVersion::new(10, 0, WIN11_MIN_BUILD - 1);
assert_eq!(
plan_corner(WindowCorner::System, Some(below), Backend::Gpu).source,
CornerSource::Square,
"build 21999 = Win10 22H2 家族末尾,不能算 Win11"
);
}
/// 日志行的形状:四个字段都在,且 `excluded_reason=none` 与 `reason=user` 不许混。
///
/// 为什么要钉这一条:这行是「后来的人分辨没生效 / 生效了看不见」的唯一依据(任务书原话),
/// 少一栏就等于把这条线索丢掉。
#[test]
fn the_log_line_carries_every_field() {
let dwm = plan_corner(WindowCorner::System, Some(win11()), Backend::Gpu)
.log_line(WindowCorner::System);
assert!(dwm.starts_with("corner=policy=system"), "{dwm}");
assert!(dwm.contains("source=dwm"), "{dwm}");
assert!(dwm.contains("os=10.0.26100"), "{dwm}");
assert!(dwm.contains("excluded_reason=none"), "{dwm}");
assert!(dwm.contains("radius=system"), "{dwm}");
let square = plan_corner(WindowCorner::Square, Some(win11()), Backend::Gpu)
.log_line(WindowCorner::Square);
assert!(square.contains("source=square"), "{square}");
assert!(square.contains("excluded_reason=user"), "{square}");
let unknown: CornerPlan = plan_corner(WindowCorner::System, None, Backend::Gpu);
assert!(unknown
.log_line(WindowCorner::System)
.contains("os=unknown"));
}
}
/// **日志行的形状**,用 M116 探针窗口的**真实读数**构造(`DevDocs/artifacts/m116/probe-table.md`)。
///
/// 抓得住:字段被删/改名/顺序错乱(`excluded_reason` 少一栏、`radius=system` 丢了、
/// `frame` / `layered` 被挪出这一行)——这些都会让「没生效 vs 生效了看不见」的区分重新变成猜谜。
/// 抓不住:产品进程里那一行到底长什么样(本 mission 没构建 bin;合并态由 tower 编 `--bins` 补)。
#[test]
fn the_log_line_shape_is_pinned_with_the_measured_readings() {
use super::{format_line, plan_corner, CornerPlan};
use crate::Backend;
use unlucky_presenter::prefs::WindowsVersion;
// 本机实测:Server 2025 / build 26100;读回 `E_INVALIDARG`;popup 窗口 layered=on。
let os = WindowsVersion::new(10, 0, 26_100);
let plan: CornerPlan = plan_corner(WindowCorner::System, Some(os), Backend::Gpu);
let line = format_line(
"boot",
&plan,
WindowCorner::System,
"pref=2 readback=? read_hr=0x80070057",
"frame=popup layered=on",
);
assert_eq!(
line,
"[corner] boot corner=policy=system source=dwm os=10.0.26100 excluded_reason=none radius=system pref=2 readback=? read_hr=0x80070057 frame=popup layered=on"
);
// 设置里选「直角」:原因码必须从 `none` 变成 `user`,其余字段不动。
let square = format_line(
"change",
&plan_corner(WindowCorner::Square, Some(os), Backend::Gpu),
WindowCorner::Square,
"pref=1 readback=? read_hr=0x80070057",
"frame=popup layered=off",
);
assert!(
square.contains("corner=policy=square source=square"),
"{square}"
);
assert!(square.contains("excluded_reason=user"), "{square}");
assert!(square.contains("frame=popup layered=off"), "{square}");
// Win10:原因码是 `os-build`(GPU 档)/ `cpu-tier`(CPU 档),来源仍是 square。
let win10 = WindowsVersion::new(10, 0, 19_045);
let os_build =
plan_corner(WindowCorner::System, Some(win10), Backend::Gpu).log_line(WindowCorner::System);
assert!(
os_build.contains("source=square") && os_build.contains("excluded_reason=os-build"),
"{os_build}"
);
let cpu =
plan_corner(WindowCorner::System, Some(win10), Backend::Cpu).log_line(WindowCorner::System);
assert!(cpu.contains("excluded_reason=cpu-tier"), "{cpu}");
// 探测不到版本(macOS / X11 / Wayland 落到同一格):`unknown-os`,不许猜 Win11。
let unknown =
plan_corner(WindowCorner::System, None, Backend::Gpu).log_line(WindowCorner::System);
assert!(
unknown.contains("os=unknown") && unknown.contains("excluded_reason=unknown-os"),
"{unknown}"
);
}
+8 -2
View File
@@ -1,10 +1,16 @@
//! Win32 专属的窗口能力。
//!
//! 目前只有一个 [`transparency`]:D36 定案的整窗 α 唯一实现落点。
//! | 子模块 | 是什么 | 有没有 `unsafe` |
//! |---|---|---|
//! | [`transparency`] | D36 定案的整窗 α 唯一实现落点(也是 M116 的 DWM 圆角落点) | **有**(全仓唯一) |
//! | [`corner`] | M116 的圆角**判据**:策略 × 系统版本 × 渲染档 → 来源与原因码 | 没有(纯函数) |
//!
//! 目录形态(而非 `win32.rs` 单文件)是为了让「每个平台一份实现」在路径上一眼可见:
//! 将来 macOS / Wayland 的对应能力各自成目录,互不干扰。
//!
//! **非 Windows 平台也在编译**:`transparency` 里的公开函数有同签名的空实现,
//! 所以调用点不需要 `#[cfg]`,行为(返回「本平台不支持」)可被测试断言。
//! `corner` 的判据在探测不到系统版本时给出「直角 + `unknown-os`」,所以调用点不需要 `#[cfg]`,
//! 行为(返回「本平台不支持」)可被测试断言。
pub mod corner;
pub mod transparency;
+231 -224
View File
@@ -44,128 +44,43 @@
// D36:这是全仓唯一的 unsafe 落点,CI 的 `unsafe-scope` job 会比对文件集合。
#![allow(unsafe_code)]
mod alpha;
mod dwm;
mod exstyle;
mod probe;
pub use dwm::{apply_corner, style_summary, CornerOutcome};
pub use exstyle::{click_through, is_click_through_style, WS_EX_TRANSPARENT};
pub use probe::{controlled_experiment, TABLE_HEADER};
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
/// Win32 的 `LWA_ALPHA` 标志位:只改整窗 α(`0x2`)。
///
/// 单独立成常量是为了让「写错成 `LWA_COLORKEY`(`0x1`)等于什么都没做」这件事有断言盯着。
pub const LWA_ALPHA: u32 = 0x0000_0002;
/// `WS_EX_LAYERED`:分层窗口扩展样式(`0x0008_0000`)。**整窗 α 的调用前提**。
pub const WS_EX_LAYERED: isize = 0x0008_0000;
/// `GWL_EXSTYLE`:扩展样式在 `Get/SetWindowLongPtrW` 里的槽位号(`-20`)。
pub const GWL_EXSTYLE: i32 = -20;
/// 一次整窗 α 应用的结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlphaOutcome {
/// α ≥ 1.0:完全不透明,**不调 FFI**(也就不需要分层窗口)。
Opaque,
/// 已下发,`byte` 是 Win32 收到的 `0..=255`。
Applied {
/// 量化后的字节值。
byte: u8,
},
/// 拒绝执行。
Rejected(AlphaRejection),
/// 非 Windows 平台:空实现,什么都没做。
Unsupported,
/// FFI 自己失败(如句柄无效),`code` 来自 `GetLastError`。
Failed {
/// Win32 错误码(`1400` = `ERROR_INVALID_WINDOW_HANDLE`)。
code: u32,
},
}
/// [`AlphaOutcome::Rejected`] 的原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlphaRejection {
/// 没有可用的 α(`None` / 非正 / 非有限)——整窗穿透的拒绝在这里拦住。
NoAlpha,
/// 句柄为 0:还没拿到窗口,或窗口已销毁。
NoWindow,
}
/// α → Win32 的 `bAlpha`(`0..=255`)。
///
/// 输入先夹到 [`WINDOW_ALPHA_MIN`]`..=1.0`(NaN 取下限):调用方即使漏了
/// [`super::super::effective_window_alpha`] 的过滤,也不会把窗口写成穿透。
///
/// **向上取整**(`ceil`)是刻意的:`bAlpha/255` 才是实际下发的 α,用 `round` 会让
/// `0.08×255 = 20.4 → 20`,实际下发 `20/255 = 0.0784`,反而**低于** D36 的下限。
/// 因此下发值可能比请求值略高(最多 1/255 ≈ 0.004),但绝不会低于下限。
#[must_use]
pub fn alpha_byte(alpha: f64) -> u8 {
let clamped = if alpha.is_nan() {
WINDOW_ALPHA_MIN
} else {
alpha.clamp(WINDOW_ALPHA_MIN, 1.0)
};
(clamped * 255.0).ceil() as u8
}
/// 给窗口补上 `WS_EX_LAYERED`(幂等)。
///
/// ## 为什么这件事必须在这里(而不是「窗口创建侧」)
///
/// 加扩展样式只有 `GetWindowLongPtrW` / `SetWindowLongPtrW` 一条路,都是 `unsafe`;而
/// [`super::super`] 的 CI 门禁(`.github/workflows/guards.yml` 的 `unsafe-scope`)要求
/// 全仓显式放行 `unsafe_code` 的文件集合**恰好等于本文件**。所以窗口创建侧的
/// `app/boot.rs` 只负责「拿句柄 → 调本函数 → 调 [`apply_window_alpha`]」,
/// 不新增第二个放行文件。
///
/// ## 为什么创建侧无法预先带上这一位
///
/// iced 0.14 的 `window::Settings` 没有扩展样式入口;winit 0.30 只在
/// `IGNORE_CURSOR_EVENT`(鼠标穿透,`WS_EX_TRANSPARENT | WS_EX_LAYERED`)时置位,
/// `transparent: true` 走的是 DWM 那条路(`window.rs` 的 `ENABLE BLUR BEHIND WINDOW`),
/// 与 `LWA_ALPHA` 无关。所以只能拿到句柄后补。
///
/// ## 返回值
///
/// `true` = 这一位**已就位**(原本就有,或本次加上了)。判据是**再加读一次**验证,
/// 而不是信 `SetWindowLongPtrW` 的返回值:Win32 的它「失败返回 0 且成功也可能返回 0」
/// (前值就是 0 时),只能靠回读取证。句柄为 0 / 无效 → `false`(`GetWindowLongPtrW`
/// 也读不到这一位)。
pub fn ensure_layered(hwnd: isize) -> bool {
if hwnd == 0 {
return false;
}
if has_layered(hwnd) {
return true;
}
raw_set_ex_style(hwnd, raw_ex_style(hwnd) | WS_EX_LAYERED);
has_layered(hwnd)
}
pub use alpha::{
alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE,
LWA_ALPHA, WS_EX_LAYERED,
};
/// 读扩展样式(跨位宽的唯一落点:64 位走 `GetWindowLongPtrW`,32 位走 `GetWindowLongW`)。
#[cfg(all(target_os = "windows", target_pointer_width = "64"))]
fn raw_ex_style(hwnd: isize) -> isize {
pub(super) fn raw_ex_style(hwnd: isize) -> isize {
// SAFETY: 只读一个窗口属性,按值传参、不持有指针、无所有权转移;无效句柄只会返回 0。
unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) }
}
/// 32 位目标:符号是 `GetWindowLongW`(`link_name` 已映射),返回 `i32`,扩到 `isize`。
#[cfg(all(target_os = "windows", target_pointer_width = "32"))]
fn raw_ex_style(hwnd: isize) -> isize {
pub(super) fn raw_ex_style(hwnd: isize) -> isize {
// SAFETY: 同 64 位分支。
unsafe { isize::from(GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) }
}
/// 非 Windows:没有扩展样式这个概念,读 0(`ensure_layered` 因此恒 `false`)。
#[cfg(not(target_os = "windows"))]
fn raw_ex_style(_hwnd: isize) -> isize {
pub(super) fn raw_ex_style(_hwnd: isize) -> isize {
0
}
/// 写扩展样式(位宽分档同上)。
#[cfg(all(target_os = "windows", target_pointer_width = "64"))]
fn raw_set_ex_style(hwnd: isize, style: isize) {
pub(super) fn raw_set_ex_style(hwnd: isize, style: isize) {
// SAFETY: 同 `raw_ex_style`:按值传参、不持有指针;返回值刻意不用(改完靠回读取证)。
unsafe {
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, style);
@@ -174,7 +89,7 @@ fn raw_set_ex_style(hwnd: isize, style: isize) {
/// 32 位目标:符号是 `SetWindowLongW`;扩展样式本身就是 32 位量,收窄无损。
#[cfg(all(target_os = "windows", target_pointer_width = "32"))]
fn raw_set_ex_style(hwnd: isize, style: isize) {
pub(super) fn raw_set_ex_style(hwnd: isize, style: isize) {
let style = i32::try_from(style).unwrap_or(i32::MAX);
// SAFETY: 同 64 位分支。
unsafe {
@@ -184,10 +99,10 @@ fn raw_set_ex_style(hwnd: isize, style: isize) {
/// 非 Windows:空实现。
#[cfg(not(target_os = "windows"))]
fn raw_set_ex_style(_hwnd: isize, _style: isize) {}
pub(super) fn raw_set_ex_style(_hwnd: isize, _style: isize) {}
/// 回读判据:这一位在不在扩展样式里。
fn has_layered(hwnd: isize) -> bool {
pub(super) fn has_layered(hwnd: isize) -> bool {
raw_ex_style(hwnd) & WS_EX_LAYERED != 0
}
@@ -205,27 +120,6 @@ pub(super) fn window_exists(hwnd: isize) -> bool {
(unsafe { IsWindow(hwnd) }) != 0
}
/// 把整窗 α 下发到窗口。
///
/// `alpha` 一般直接来自 [`super::super::effective_window_alpha`](`None` = 拒绝整窗穿透)。
/// 本函数是**执行层**:不再看渲染后端、不读环境变量,只认「有没有一个合法的值」+
/// 「有没有句柄」,然后(仅 Windows)调一次 `SetLayeredWindowAttributes`。
///
/// 调用前提是窗口已带 `WS_EX_LAYERED`(见 [`ensure_layered`]):没有它 `SetLayeredWindowAttributes`
/// 一定失败(Win32 返回 0,本机实测 `GetLastError` 常见 87 / `ERROR_INVALID_PARAMETER`)。
pub fn apply_window_alpha(hwnd: isize, alpha: Option<f64>) -> AlphaOutcome {
let Some(alpha) = alpha.filter(|value| value.is_finite() && *value > 0.0) else {
return AlphaOutcome::Rejected(AlphaRejection::NoAlpha);
};
if alpha >= 1.0 {
return AlphaOutcome::Opaque;
}
if hwnd == 0 {
return AlphaOutcome::Rejected(AlphaRejection::NoWindow);
}
set_layered(hwnd, alpha_byte(alpha))
}
#[cfg(target_os = "windows")]
#[link(name = "user32")]
extern "system" {
@@ -259,15 +153,227 @@ extern "system" {
#[cfg(target_pointer_width = "32")]
#[link_name = "SetWindowLongW"]
fn SetWindowLongPtrW(hwnd: isize, index: i32, new_value: i32) -> i32;
/// `GetWindowRect`:窗口矩形的屏幕坐标(**含** DWM 加出来的不可见边框)。
///
/// 与 `DWMWA_EXTENDED_FRAME_BOUNDS` 对照,是 M116 探针里「DWM 到底给这个窗口加了多宽的
/// 边框」这条差值的来源:差值 ≈ 系统认为的边框带宽度。
fn GetWindowRect(hwnd: isize, rect: *mut WinRect) -> i32;
/// `CreateWindowExW`:M116 的**受控对照实验**要用它造三种样式的窗口(同进程)。
///
/// 只用系统自带类 `STATIC`(不需要 `RegisterClassW`,也就没有窗口过程要写)。
fn CreateWindowExW(
ex_style: u32,
class: *const u16,
name: *const u16,
style: u32,
x: i32,
y: i32,
width: i32,
height: i32,
parent: isize,
menu: isize,
instance: isize,
param: *mut core::ffi::c_void,
) -> isize;
/// `DestroyWindow`:探针窗口用完即毁(不泄漏 HWND)。
fn DestroyWindow(hwnd: isize) -> i32;
/// `ShowWindow`:DWM 只管理**可见**窗口,所以探针窗口必须真显示一次才有读数。
fn ShowWindow(hwnd: isize, command: i32) -> i32;
}
/// `RECT`(`GetWindowRect` 的 out 参数)。字段顺序照 Win32 头文件,`repr(C)` 保证布局。
#[cfg(target_os = "windows")]
#[repr(C)]
#[derive(Debug, Clone, Copy, Default)]
pub(super) struct WinRect {
/// 左
pub left: i32,
/// 上
pub top: i32,
/// 右
pub right: i32,
/// 下
pub bottom: i32,
}
#[cfg(target_os = "windows")]
fn set_layered(hwnd: isize, byte: u8) -> AlphaOutcome {
#[link(name = "dwmapi")]
extern "system" {
/// `DwmSetWindowAttribute`:M116 圆角的唯一落点(`DWMWA_WINDOW_CORNER_PREFERENCE`)。
///
/// 返回 `HRESULT`:`S_OK`(0) = 成功;属性不被这个 Windows 版本认识时是 `E_INVALIDARG`
/// (`0x8007_0057`)——Win10 上就是这个返回值,**不是**「设了但没效果」。
fn DwmSetWindowAttribute(
hwnd: isize,
attribute: u32,
value: *const core::ffi::c_void,
size: u32,
) -> i32;
/// `DwmGetWindowAttribute`:读回 DWM 侧的属性(探针与日志的证据来源)。
fn DwmGetWindowAttribute(
hwnd: isize,
attribute: u32,
value: *mut core::ffi::c_void,
size: u32,
) -> i32;
}
/// 写一个 `u32` 的 DWM 属性,返回 `HRESULT`(0 = `S_OK`)。
#[cfg(target_os = "windows")]
pub(super) fn dwm_set_u32(hwnd: isize, attribute: u32, value: u32) -> i32 {
// SAFETY: `value` 是栈上的 POD,指针只在调用期间有效,`size` 与它逐字节一致;
// DWM 只按值读取,不保留指针。
unsafe {
DwmSetWindowAttribute(
hwnd,
attribute,
(&raw const value).cast::<core::ffi::c_void>(),
u32::try_from(std::mem::size_of::<u32>()).unwrap_or(4),
)
}
}
/// `HRESULT` 的成功判据:**看严重性位**(bit 31),不是「等于 0」。
///
/// Win32 里成功码不只有 `S_OK`(0):`S_FALSE`(1) 与各种 `S_*` / 信息级码都是成功。
/// M116 实测:本机 DWM 回读偏好返回 `0x7FF8FFA9`(正数),`hr == 0` 判据会把它读成失败,
/// 于是「属性接受了吗」变成谜。本函数把这条判据收成一处,测试钉住正负两侧。
#[must_use]
pub const fn hresult_ok(hr: i32) -> bool {
hr >= 0
}
/// 读一个 `u32` 的 DWM 属性**并保留 `HRESULT`**(M116 探针要它:读不回来时得知道为什么)。
#[cfg(target_os = "windows")]
pub(super) fn dwm_get_u32_hr(hwnd: isize, attribute: u32) -> (Option<u32>, u32) {
let mut value: u32 = 0;
// SAFETY: out 指针指向本栈帧里的 `u32`,`size` 与之一致;失败时 DWM 不写。
let hr = unsafe {
DwmGetWindowAttribute(
hwnd,
attribute,
(&raw mut value).cast::<core::ffi::c_void>(),
u32::try_from(std::mem::size_of::<u32>()).unwrap_or(4),
)
};
// 判据是**严重性位**(bit 31),不是「等于 0」:`S_FALSE` / 信息级码也是成功。
// ⚠️ 报错时给的是 `hr as u32`(原始位),**不是** `unsigned_abs()`——后者会把
// `E_INVALIDARG`(`0x8007_0057`)印成 `0x7FF8_FFA9`(M116 实测踩过:负数取绝对值
// 得到的是「补码的绝对值」,不是 HRESULT)。`hresult_ok` 与这条打印规则一起被测试钉住。
(hresult_ok(hr).then_some(value), hr as u32)
}
/// 读一个 `u32` 的 DWM 属性;`HRESULT` 非 0 返回 `None`(**不许**把失败读成 0)。
#[cfg(target_os = "windows")]
pub(super) fn dwm_get_u32(hwnd: isize, attribute: u32) -> Option<u32> {
dwm_get_u32_hr(hwnd, attribute).0
}
/// 读 `DWMWA_EXTENDED_FRAME_BOUNDS` 这类 `RECT` 属性;失败返回 `None`。
#[cfg(target_os = "windows")]
pub(super) fn dwm_get_rect(hwnd: isize, attribute: u32) -> Option<WinRect> {
let mut rect = WinRect::default();
// SAFETY: 同 `dwm_get_u32`,只是 out 类型换成 `RECT`。
let hr = unsafe {
DwmGetWindowAttribute(
hwnd,
attribute,
(&raw mut rect).cast::<core::ffi::c_void>(),
u32::try_from(std::mem::size_of::<WinRect>()).unwrap_or(16),
)
};
(hr == 0).then_some(rect)
}
/// `GetWindowRect`:窗口矩形(屏幕坐标)。失败返回 `None`。
#[cfg(target_os = "windows")]
pub(super) fn window_rect(hwnd: isize) -> Option<WinRect> {
let mut rect = WinRect::default();
// SAFETY: out 指针指向本栈帧的 `RECT`;`GetWindowRect` 只在成功时写它。
let ok = unsafe { GetWindowRect(hwnd, &raw mut rect) };
(ok != 0).then_some(rect)
}
/// 造一个探针窗口(受控对照实验用)。`0` = 创建失败。
///
/// 类名用系统自带的 `STATIC`:不需要 `RegisterClassW`,也就不必在这个文件里再写一个窗口过程。
#[cfg(target_os = "windows")]
pub(super) fn create_probe_window(style: u32, ex_style: u32) -> isize {
let class: Vec<u16> = "STATIC\0".encode_utf16().collect();
let title: Vec<u16> = "ULL M116 corner probe\0".encode_utf16().collect();
// SAFETY: 两个指针都指向本帧里以 NUL 结尾的 UTF-16 缓冲;其余参数全是按值 POD。
// 失败返回 0,调用方按「拿不到句柄」处理,不会产生未定义行为。
unsafe {
CreateWindowExW(
ex_style,
class.as_ptr(),
title.as_ptr(),
style,
40,
40,
420,
260,
0,
0,
0,
core::ptr::null_mut(),
)
}
}
/// 让探针窗口可见(DWM 只管理可见窗口),`SW_SHOWNA` = 显示但不激活。
#[cfg(target_os = "windows")]
pub(super) fn show_probe_window(hwnd: isize) {
const SW_SHOWNA: i32 = 8;
// SAFETY: 按值传句柄;无效句柄只会让 Win32 返回 0。
unsafe {
ShowWindow(hwnd, SW_SHOWNA);
}
}
/// 销毁探针窗口(成对使用,避免 HWND 泄漏)。
#[cfg(target_os = "windows")]
pub(super) fn destroy_probe_window(hwnd: isize) {
if hwnd == 0 {
return;
}
// SAFETY: 只对本模块 `create_probe_window` 造出来的句柄调用;销毁后不再使用它。
unsafe {
DestroyWindow(hwnd);
}
}
/// 读窗口样式位(`GWL_STYLE` = `-16`)。探针用它证明「无边框 = `WS_POPUP`」这条前提。
#[cfg(target_os = "windows")]
pub(super) fn window_style(hwnd: isize) -> isize {
const GWL_STYLE: i32 = -16;
// SAFETY: 同 `raw_ex_style`:只读一个窗口属性,按值传参、不持有指针。
unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) }
}
#[cfg(target_os = "windows")]
pub(super) fn set_layered(hwnd: isize, byte: u8) -> AlphaOutcome {
// SAFETY: 四个参数都是按值传入的 POD;`flags` 只带 LWA_ALPHA,于是系统忽略 `color_key`
// (传 0 与之无关)。句柄由调用方给出:无效句柄只会让 Win32 返回 0(走下面的 Failed 分支),
// 不会造成未定义行为——本调用不持有指针,也没有释放语义。
let ok = unsafe { SetLayeredWindowAttributes(hwnd, 0, byte, LWA_ALPHA) };
if ok == 0 {
// ⚠️ **这里的 `unsigned_abs()` 是有意的,别跟 DWM 那几处「统一」**(tower 裁定 2026-11-03):
//
// - 这里取的是 `GetLastError()` 的 **DWORD**(`raw_os_error()` 给的是被塞进 `i32` 的 DWORD,
// 例如 `87` / `1400`),它不是 HRESULT,没有严重性位;`unsigned_abs` 只是把
// 「高位被当符号」的那点噪声抹掉,语义仍是「错误号」。
// - `DwmGet/SetWindowAttribute` 返回的是 **HRESULT**:判成功看 bit 31(`hresult_ok`),
// 打印必须用 `hr as u32` 的原始位——用 `unsigned_abs()` 会把 `E_INVALIDARG`
// (`0x8007_0057`)印成 `0x7FF8_FFA9`(M116 实测踩过,见 `dwm_get_u32_hr` 的注释)。
//
// 两种「错误号」不同源,所以打印方式也不该相同。这条由
// `tests::dword_error_codes_and_hresults_are_printed_differently` 钉住。
let code = std::io::Error::last_os_error()
.raw_os_error()
.unwrap_or(0)
@@ -278,108 +384,9 @@ fn set_layered(hwnd: isize, byte: u8) -> AlphaOutcome {
}
#[cfg(not(target_os = "windows"))]
fn set_layered(_hwnd: isize, _byte: u8) -> AlphaOutcome {
pub(super) fn set_layered(_hwnd: isize, _byte: u8) -> AlphaOutcome {
AlphaOutcome::Unsupported
}
#[cfg(test)]
mod tests {
use super::{
alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE,
LWA_ALPHA, WS_EX_LAYERED,
};
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
#[test]
fn layered_constants_match_the_win32_header() {
// 抓得住:常量写错(例如把 GWL_EXSTYLE 写成 -16 = GWL_STYLE,或 WS_EX_LAYERED 少写一个 0)。
// 抓不住:这些值在真实窗口上是否真的生效(那要靠真机日志,见 app 侧)。
assert_eq!(WS_EX_LAYERED, 0x0008_0000);
assert_eq!(GWL_EXSTYLE, -20);
}
#[test]
fn ensure_layered_refuses_a_missing_or_invalid_window() {
// 句柄 0 = 还没拿到窗口:不许「假设成功」。
assert!(!ensure_layered(0), "句柄 0 必须返回 false");
// 1 号句柄永远无效:读不到扩展样式,也就加不上这一位。
assert!(!ensure_layered(1), "无效句柄必须返回 false(不 panic)");
}
#[test]
fn alpha_byte_quantizes_upward_never_below_the_floor() {
assert_eq!(alpha_byte(1.0), 255);
assert_eq!(alpha_byte(0.5), 128, "0.5×255=127.5 → 向上取整 128");
assert_eq!(
alpha_byte(WINDOW_ALPHA_MIN),
21,
"0.08×255=20.4 → 向上取整 21;用 round 会得到 20,实际下发 20/255=0.0784 低于 D36 下限"
);
// 漏过预处理的坏值也不会变成「穿透」:一律落到下限字节。
for bad in [0.0, -3.0, f64::NAN] {
assert_eq!(
alpha_byte(bad),
alpha_byte(WINDOW_ALPHA_MIN),
"{bad} 应取下限"
);
}
assert_eq!(
LWA_ALPHA, 0x2,
"0x2 就是 LWA_ALPHA;写成 0x1 会变成色键、等于没设 α"
);
}
#[test]
fn applied_alpha_is_never_below_the_floor() {
// 真正生效的 α 是 `bAlpha / 255`,不是传进来的 f64。这条把「改用 round」的行为钉死:
// round 下 0.08 → 20 → 20/255 = 0.0784 < 0.08 → 这里红。
for requested in [WINDOW_ALPHA_MIN, 0.081, 0.1, 0.33, 0.5, 0.77, 0.999, 1.0] {
let byte = alpha_byte(requested);
let applied = f64::from(byte) / 255.0;
assert!(
applied >= WINDOW_ALPHA_MIN - 1e-12,
"requested={requested} → byte={byte} → 实际 α={applied},低于下限 {WINDOW_ALPHA_MIN}"
);
assert!(
applied <= 1.0,
"requested={requested} → 实际 α={applied} 超过 1.0"
);
}
}
#[test]
fn missing_or_opaque_alpha_never_touches_the_window() {
for none in [None, Some(0.0), Some(-0.5), Some(f64::NAN)] {
assert_eq!(
apply_window_alpha(1234, none),
AlphaOutcome::Rejected(AlphaRejection::NoAlpha),
"{none:?} 应被拒绝(整窗穿透不许发生)"
);
}
// α = 1.0:两个平台都返回 Opaque —— 正好证明「不调 FFI」不是靠 Unsupported 混过去的。
assert_eq!(apply_window_alpha(1234, Some(1.0)), AlphaOutcome::Opaque);
// 合法 α 但没有句柄:拒绝,绝不下发。
assert_eq!(
apply_window_alpha(0, Some(0.5)),
AlphaOutcome::Rejected(AlphaRejection::NoWindow)
);
}
#[cfg(target_os = "windows")]
#[test]
fn invalid_handle_reports_a_win32_failure_instead_of_panicking() {
// 用 1 号句柄(永远无效)真调一次 API:验的是**返回值语义**,不是观感。
match apply_window_alpha(1, Some(0.5)) {
AlphaOutcome::Failed { code } => {
assert_eq!(code, 1400, "ERROR_INVALID_WINDOW_HANDLE")
}
other => panic!("无效句柄应报 Failed,实际 {other:?}"),
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn non_windows_stub_reports_unsupported() {
assert_eq!(apply_window_alpha(1, Some(0.5)), AlphaOutcome::Unsupported);
}
}
mod tests;
@@ -0,0 +1,128 @@
//! 整窗 α 的**判据与执行**(D36)——从 `transparency.rs` 搬出来,那里要留行数给 DWM 的 FFI。
//!
//! ## 本文件零 `unsafe`、零 `extern`
//!
//! 全仓 `unsafe` 只允许出现在 [`super`](`transparency.rs`)一个文件里,判据是「**带放行属性的
//! 文件集合恰好等于它**」。本文件要的 FFI 全部由 `super` 提供安全包装
//! (`raw_ex_style` / `raw_set_ex_style` / `has_layered` / `set_layered`),这里只做钳位、量化与三态映射。
//!
//! ⚠️ **别把 FFI 写回来**(`exstyle.rs` 的模块文档里有同一句,M100 r1 复审实测抓过一次):
//! lint 级别沿模块树继承,在这里写 `unsafe` 块也**编得过**、门禁也照样绿,但「唯一落点」就破了。
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
use super::{has_layered, raw_ex_style, raw_set_ex_style, set_layered};
/// Win32 的 `LWA_ALPHA` 标志位:只改整窗 α(`0x2`)。
///
/// 单独立成常量是为了让「写错成 `LWA_COLORKEY`(`0x1`)等于什么都没做」这件事有断言盯着。
pub const LWA_ALPHA: u32 = 0x0000_0002;
/// `WS_EX_LAYERED`:分层窗口扩展样式(`0x0008_0000`)。**整窗 α 的调用前提**。
pub const WS_EX_LAYERED: isize = 0x0008_0000;
/// `GWL_EXSTYLE`:扩展样式在 `Get/SetWindowLongPtrW` 里的槽位号(`-20`)。
pub const GWL_EXSTYLE: i32 = -20;
/// 一次整窗 α 应用的结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlphaOutcome {
/// α ≥ 1.0:完全不透明,**不调 FFI**(也就不需要分层窗口)。
Opaque,
/// 已下发,`byte` 是 Win32 收到的 `0..=255`。
Applied {
/// 量化后的字节值。
byte: u8,
},
/// 拒绝执行。
Rejected(AlphaRejection),
/// 非 Windows 平台:空实现,什么都没做。
Unsupported,
/// FFI 自己失败(如句柄无效),`code` 来自 `GetLastError`。
Failed {
/// Win32 错误码(`1400` = `ERROR_INVALID_WINDOW_HANDLE`)。
code: u32,
},
}
/// [`AlphaOutcome::Rejected`] 的原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlphaRejection {
/// 没有可用的 α(`None` / 非正 / 非有限)——整窗穿透的拒绝在这里拦住。
NoAlpha,
/// 句柄为 0:还没拿到窗口,或窗口已销毁。
NoWindow,
}
/// α → Win32 的 `bAlpha`(`0..=255`)。
///
/// 输入先夹到 [`WINDOW_ALPHA_MIN`]`..=1.0`(NaN 取下限):调用方即使漏了
/// [`super::super::effective_window_alpha`] 的过滤,也不会把窗口写成穿透。
///
/// **向上取整**(`ceil`)是刻意的:`bAlpha/255` 才是实际下发的 α,用 `round` 会让
/// `0.08×255 = 20.4 → 20`,实际下发 `20/255 = 0.0784`,反而**低于** D36 的下限。
/// 因此下发值可能比请求值略高(最多 1/255 ≈ 0.004),但绝不会低于下限。
#[must_use]
pub fn alpha_byte(alpha: f64) -> u8 {
let clamped = if alpha.is_nan() {
WINDOW_ALPHA_MIN
} else {
alpha.clamp(WINDOW_ALPHA_MIN, 1.0)
};
(clamped * 255.0).ceil() as u8
}
/// 给窗口补上 `WS_EX_LAYERED`(幂等)。
///
/// ## 为什么这件事必须在这里(而不是「窗口创建侧」)
///
/// 加扩展样式只有 `GetWindowLongPtrW` / `SetWindowLongPtrW` 一条路,都是 `unsafe`;而
/// [`super::super`] 的 CI 门禁(`.github/workflows/guards.yml` 的 `unsafe-scope`)要求
/// 全仓显式放行 `unsafe_code` 的文件集合**恰好等于本文件**。所以窗口创建侧的
/// `app/boot.rs` 只负责「拿句柄 → 调本函数 → 调 [`apply_window_alpha`]」,
/// 不新增第二个放行文件。
///
/// ## 为什么创建侧无法预先带上这一位
///
/// iced 0.14 的 `window::Settings` 没有扩展样式入口;winit 0.30 只在
/// `IGNORE_CURSOR_EVENT`(鼠标穿透,`WS_EX_TRANSPARENT | WS_EX_LAYERED`)时置位,
/// `transparent: true` 走的是 DWM 那条路(`window.rs` 的 `ENABLE BLUR BEHIND WINDOW`),
/// 与 `LWA_ALPHA` 无关。所以只能拿到句柄后补。
///
/// ## 返回值
///
/// `true` = 这一位**已就位**(原本就有,或本次加上了)。判据是**再加读一次**验证,
/// 而不是信 `SetWindowLongPtrW` 的返回值:Win32 的它「失败返回 0 且成功也可能返回 0」
/// (前值就是 0 时),只能靠回读取证。句柄为 0 / 无效 → `false`(`GetWindowLongPtrW`
/// 也读不到这一位)。
pub fn ensure_layered(hwnd: isize) -> bool {
if hwnd == 0 {
return false;
}
if has_layered(hwnd) {
return true;
}
raw_set_ex_style(hwnd, raw_ex_style(hwnd) | WS_EX_LAYERED);
has_layered(hwnd)
}
/// 把整窗 α 下发到窗口。
///
/// `alpha` 一般直接来自 [`super::super::effective_window_alpha`](`None` = 拒绝整窗穿透)。
/// 本函数是**执行层**:不再看渲染后端、不读环境变量,只认「有没有一个合法的值」+
/// 「有没有句柄」,然后(仅 Windows)调一次 `SetLayeredWindowAttributes`。
///
/// 调用前提是窗口已带 `WS_EX_LAYERED`(见 [`ensure_layered`]):没有它 `SetLayeredWindowAttributes`
/// 一定失败(Win32 返回 0,本机实测 `GetLastError` 常见 87 / `ERROR_INVALID_PARAMETER`)。
pub fn apply_window_alpha(hwnd: isize, alpha: Option<f64>) -> AlphaOutcome {
let Some(alpha) = alpha.filter(|value| value.is_finite() && *value > 0.0) else {
return AlphaOutcome::Rejected(AlphaRejection::NoAlpha);
};
if alpha >= 1.0 {
return AlphaOutcome::Opaque;
}
if hwnd == 0 {
return AlphaOutcome::Rejected(AlphaRejection::NoWindow);
}
set_layered(hwnd, alpha_byte(alpha))
}
@@ -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=?");
}
@@ -0,0 +1,250 @@
//! M116 的**受控对照实验**:同进程里造三种窗口样式,逐个读 DWM 属性。
//!
//! ## 为什么需要它
//!
//! 用户的观察是「同一台机器上别的窗口都有圆角,只有我们的没有」。于是「没圆角」至少有三种
//! 可能:①这是 DWM 的默认行为(我们没申请);②我们用什么样式把它 opt-out 了
//! (`decorations=false` ⇒ `WS_POPUP`,以及 D36 的整窗 `WS_EX_LAYERED`);③属性根本设不上。
//!
//! 只在自己的窗口上读一次,分不清 ①②③。所以这里**同一进程**里造三个窗口,除「样式」外
//! 条件全相同:
//!
//! | 标签 | 样式 | 对应现实 |
//! |---|---|---|
//! | `decorated` | `WS_OVERLAPPEDWINDOW` | 用户说的「别的窗口」——Win11 上就是圆的 |
//! | `popup` | `WS_POPUP` | winit 在 `decorations = false` 下给的样式 |
//! | `popup+layered` | `WS_POPUP` + `WS_EX_LAYERED` + `LWA_ALPHA 205` | 本产品窗口的完整形状(D36) |
//!
//! ## 这个实验能证明什么、不能证明什么
//!
//! **能**:属性层能不能开(`DwmSetWindowAttribute` 的 `HRESULT`)、开完 DWM 侧回读是多少、
//! 三种样式之间 `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS` 与
//! `DWMWA_EXTENDED_FRAME_BOUNDS - GetWindowRect` 的数值差是否随样式变化。
//!
//! **不能**:角上到底是不是透明的(那要合成输出像素,本机 RDP 断连拿不到)。所以本文件
//! 的读数只回答「属性层」,观感归真机验收——报告里逐条标注。
#[cfg(target_os = "windows")]
use super::alpha::{apply_window_alpha, ensure_layered};
use super::dwm::{
corner_preference_hr, extended_frame_bounds, set_preference, visible_border_thickness,
window_rect, window_style, CornerOutcome, DWMWCP_DONOTROUND, DWMWCP_ROUND,
};
/// `WS_POPUP`:无边框弹出窗口(winit 在 `decorations = false` 下用它)。
pub const WS_POPUP: u32 = 0x8000_0000;
/// `WS_OVERLAPPEDWINDOW`:普通有边框窗口(对照组的「别的窗口」)。
pub const WS_OVERLAPPEDWINDOW: u32 = 0x00CF_0000;
/// `WS_EX_LAYERED` 的 `u32` 形态(`create_probe_window` 的参数类型)。
const WS_EX_LAYERED_U32: u32 = 0x0008_0000;
/// 实验里的一行(一个窗口样式)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeRow {
/// 这一行是什么样式(见模块文档的表)。
pub label: &'static str,
/// 创建时请求的样式位。
pub style: u32,
/// 创建时请求的扩展样式位。
pub ex_style: u32,
/// 读回的 `GWL_STYLE`(证明请求的样式真的生效了)。
pub style_readback: Option<isize>,
/// 什么都没设时 DWM 侧的偏好值(`0` = `DWMWCP_DEFAULT`)。
pub preference_default: Option<u32>,
/// 读上面那一格时的 `HRESULT`(`0` = `S_OK`;非 0 说明这个会话读不出 DWM 属性)。
pub preference_read_hresult: u32,
/// 设为 [`DWMWCP_ROUND`] 的结局(含回读)。
pub set_round: CornerOutcome,
/// 设为 [`DWMWCP_DONOTROUND`] 的结局(含回读)。
pub set_square: CornerOutcome,
/// 默认状态下的可见边框厚度(像素)。
pub border_default: Option<u32>,
/// 圆角状态下的可见边框厚度(像素)。
pub border_round: Option<u32>,
/// 直角状态下的可见边框厚度(像素)。
pub border_square: Option<u32>,
/// `extended_frame_bounds - GetWindowRect` = `(dx, dy)`(各边差值之和);
/// `None` = 任一侧读不到。
pub frame_delta: Option<(i32, i32)>,
/// 窗口是否真的造出来了(`false` = 本机造不出窗口,整行无效)。
pub created: bool,
}
impl ProbeRow {
/// 表格里的一行(对齐成文本,报告直接贴)。
///
/// **一格一格拼**而不是一条长 `format!`:位置参数一多,「多了/少了一个占位符」在编译期报、
/// 在报告里却表现为整张表错位;这里列数由 [`TABLE_HEADER`] 与 [`Self::cells`] 同时决定,
/// 单测比对竖线数。
#[must_use]
pub fn describe(&self) -> String {
format!("| {} |", self.cells().join(" | "))
}
/// 各列的原始文本(与 [`TABLE_HEADER`] 同序)。
#[must_use]
pub fn cells(&self) -> Vec<String> {
let show = |value: Option<u32>| value.map_or_else(|| "?".to_owned(), |v| v.to_string());
let delta = self
.frame_delta
.map_or_else(|| "?".to_owned(), |(dx, dy)| format!("{dx:+},{dy:+}"));
vec![
format!("{:<14}", self.label),
format!("0x{:08X}", self.style),
format!("0x{:08X}", self.ex_style),
self.style_readback
.map_or_else(|| "?".to_owned(), |style| format!("0x{:08X}", style as u32)),
show(self.preference_default),
format!("0x{:08X}", self.preference_read_hresult),
show(self.border_default),
show(self.border_round),
show(self.border_square),
delta,
self.set_round.describe(),
self.set_square.describe(),
if self.created { "ok" } else { "FAILED" }.to_owned(),
]
}
}
/// 表头(与 [`ProbeRow::describe`] 的列一一对应)。
pub const TABLE_HEADER: &str = "| style | requested | exstyle | readback | pref | read_hr | bdef | brnd | bsq | Δframe | set ROUND | set SQUARE | created |";
/// 跑一遍受控对照实验(非 Windows 返回空表)。
///
/// 每个窗口用完立刻销毁:这是诊断路径,不许留 HWND。
#[must_use]
pub fn controlled_experiment() -> Vec<ProbeRow> {
let mut rows = Vec::new();
for (label, style, ex_style) in [
("decorated", WS_OVERLAPPEDWINDOW, 0u32),
("popup", WS_POPUP, 0u32),
("popup+layered", WS_POPUP, WS_EX_LAYERED_U32),
] {
rows.push(run_one(label, style, ex_style));
}
rows
}
/// 一个样式的完整读数。`created = false` 时其余字段全 `None`(**不许**拿 0 冒充读数)。
#[cfg(target_os = "windows")]
fn run_one(label: &'static str, style: u32, ex_style: u32) -> ProbeRow {
use super::{create_probe_window, destroy_probe_window, show_probe_window};
let hwnd = create_probe_window(style, ex_style);
if hwnd == 0 {
return empty_row(label, style, ex_style);
}
show_probe_window(hwnd);
// D36 的整窗 α:只有 layered 那一行才有意义(`apply_window_alpha` 对 α=1 不下发)。
if ex_style & WS_EX_LAYERED_U32 != 0 {
ensure_layered(hwnd);
let _ = apply_window_alpha(hwnd, Some(0.8));
}
let (preference_default, preference_read_hresult) = corner_preference_hr(hwnd);
let border_default = visible_border_thickness(hwnd);
let set_round = set_preference(hwnd, DWMWCP_ROUND);
let border_round = visible_border_thickness(hwnd);
let set_square = set_preference(hwnd, DWMWCP_DONOTROUND);
let border_square = visible_border_thickness(hwnd);
let frame_delta =
window_rect(hwnd)
.zip(extended_frame_bounds(hwnd))
.map(|(plain, extended)| {
(
(extended.0 - plain.0) + (extended.2 - plain.2),
(extended.1 - plain.1) + (extended.3 - plain.3),
)
});
let row = ProbeRow {
label,
style,
ex_style,
style_readback: window_style(hwnd),
preference_default,
preference_read_hresult,
set_round,
set_square,
border_default,
border_round,
border_square,
frame_delta,
created: true,
};
destroy_probe_window(hwnd);
row
}
/// 非 Windows:造不出 Win32 窗口,整行标记 `created = false`(不许编读数)。
#[cfg(not(target_os = "windows"))]
fn run_one(label: &'static str, style: u32, ex_style: u32) -> ProbeRow {
empty_row(label, style, ex_style)
}
fn empty_row(label: &'static str, style: u32, ex_style: u32) -> ProbeRow {
ProbeRow {
label,
style,
ex_style,
style_readback: None,
preference_default: None,
preference_read_hresult: 0,
set_round: CornerOutcome::UnsupportedPlatform,
set_square: CornerOutcome::UnsupportedPlatform,
border_default: None,
border_round: None,
border_square: None,
frame_delta: None,
created: false,
}
}
#[cfg(test)]
mod tests {
use super::{controlled_experiment, ProbeRow, TABLE_HEADER, WS_OVERLAPPEDWINDOW, WS_POPUP};
/// **抓得住**:样式常量写错(`WS_POPUP` 少一个 0 就变成别的位,对照实验的前提就没了)。
#[test]
fn the_style_constants_match_the_win32_header() {
assert_eq!(WS_POPUP, 0x8000_0000);
assert_eq!(WS_OVERLAPPEDWINDOW, 0x00CF_0000);
}
/// 实验永远给出三行,顺序固定(`decorated` → `popup` → `popup+layered`)。
///
/// 抓得住:有人把分层那一行删了/调换——那样「layered 会不会被 DWM 排除」这条对照就没了。
/// 抓不住:读数本身(那要么真机跑,要么看报告里的表)。
#[test]
fn the_experiment_always_covers_three_styles_in_order() {
let rows = controlled_experiment();
assert_eq!(rows.len(), 3);
let labels: Vec<_> = rows.iter().map(|row| row.label).collect();
assert_eq!(labels, ["decorated", "popup", "popup+layered"]);
assert_eq!(rows[0].style, WS_OVERLAPPEDWINDOW);
assert_eq!(rows[1].style, WS_POPUP);
assert_eq!(rows[2].ex_style & 0x0008_0000, 0x0008_0000);
}
/// 表头与每行的竖线数一致(`format!` 里多/少一列会让表格在报告里错位)。
#[test]
fn the_table_columns_line_up() {
let header_pipes = TABLE_HEADER.matches('|').count();
for row in controlled_experiment() {
let line = row.describe();
assert_eq!(
line.matches('|').count(),
header_pipes,
"列数不一致:{line}"
);
}
// 没造出窗口的行必须**没有**读数(不许用 0 冒充)。
let failed = ProbeRow {
created: false,
..controlled_experiment().into_iter().next().expect("三行")
};
assert!(failed.describe().contains("FAILED"));
}
}
@@ -0,0 +1,221 @@
//! `transparency` 的测试(M116 从 `transparency.rs` 搬出来:那个文件要留行数给 DWM 的 FFI)。
//!
//! **本文件零 `unsafe`、零 `extern`**:只调父模块的安全包装。别把 FFI 写进来——
//! 「全仓唯一允许 unsafe 的文件」是 `transparency.rs`,不是它下面的每个文件。
use super::{
alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE,
LWA_ALPHA, WS_EX_LAYERED,
};
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
#[test]
fn layered_constants_match_the_win32_header() {
// 抓得住:常量写错(例如把 GWL_EXSTYLE 写成 -16 = GWL_STYLE,或 WS_EX_LAYERED 少写一个 0)。
// 抓不住:这些值在真实窗口上是否真的生效(那要靠真机日志,见 app 侧)。
assert_eq!(WS_EX_LAYERED, 0x0008_0000);
assert_eq!(GWL_EXSTYLE, -20);
}
#[test]
fn ensure_layered_refuses_a_missing_or_invalid_window() {
// 句柄 0 = 还没拿到窗口:不许「假设成功」。
assert!(!ensure_layered(0), "句柄 0 必须返回 false");
// 1 号句柄永远无效:读不到扩展样式,也就加不上这一位。
assert!(!ensure_layered(1), "无效句柄必须返回 false(不 panic)");
}
#[test]
fn alpha_byte_quantizes_upward_never_below_the_floor() {
assert_eq!(alpha_byte(1.0), 255);
assert_eq!(alpha_byte(0.5), 128, "0.5×255=127.5 → 向上取整 128");
assert_eq!(
alpha_byte(WINDOW_ALPHA_MIN),
21,
"0.08×255=20.4 → 向上取整 21;用 round 会得到 20,实际下发 20/255=0.0784 低于 D36 下限"
);
// 漏过预处理的坏值也不会变成「穿透」:一律落到下限字节。
for bad in [0.0, -3.0, f64::NAN] {
assert_eq!(
alpha_byte(bad),
alpha_byte(WINDOW_ALPHA_MIN),
"{bad} 应取下限"
);
}
assert_eq!(
LWA_ALPHA, 0x2,
"0x2 就是 LWA_ALPHA;写成 0x1 会变成色键、等于没设 α"
);
}
#[test]
fn applied_alpha_is_never_below_the_floor() {
// 真正生效的 α 是 `bAlpha / 255`,不是传进来的 f64。这条把「改用 round」的行为钉死:
// round 下 0.08 → 20 → 20/255 = 0.0784 < 0.08 → 这里红。
for requested in [WINDOW_ALPHA_MIN, 0.081, 0.1, 0.33, 0.5, 0.77, 0.999, 1.0] {
let byte = alpha_byte(requested);
let applied = f64::from(byte) / 255.0;
assert!(
applied >= WINDOW_ALPHA_MIN - 1e-12,
"requested={requested} → byte={byte} → 实际 α={applied},低于下限 {WINDOW_ALPHA_MIN}"
);
assert!(
applied <= 1.0,
"requested={requested} → 实际 α={applied} 超过 1.0"
);
}
}
#[test]
fn missing_or_opaque_alpha_never_touches_the_window() {
for none in [None, Some(0.0), Some(-0.5), Some(f64::NAN)] {
assert_eq!(
apply_window_alpha(1234, none),
AlphaOutcome::Rejected(AlphaRejection::NoAlpha),
"{none:?} 应被拒绝(整窗穿透不许发生)"
);
}
// α = 1.0:两个平台都返回 Opaque —— 正好证明「不调 FFI」不是靠 Unsupported 混过去的。
assert_eq!(apply_window_alpha(1234, Some(1.0)), AlphaOutcome::Opaque);
// 合法 α 但没有句柄:拒绝,绝不下发。
assert_eq!(
apply_window_alpha(0, Some(0.5)),
AlphaOutcome::Rejected(AlphaRejection::NoWindow)
);
}
#[cfg(target_os = "windows")]
#[test]
fn invalid_handle_reports_a_win32_failure_instead_of_panicking() {
// 用 1 号句柄(永远无效)真调一次 API:验的是**返回值语义**,不是观感。
match apply_window_alpha(1, Some(0.5)) {
AlphaOutcome::Failed { code } => {
assert_eq!(code, 1400, "ERROR_INVALID_WINDOW_HANDLE")
}
other => panic!("无效句柄应报 Failed,实际 {other:?}"),
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn non_windows_stub_reports_unsupported() {
assert_eq!(apply_window_alpha(1, Some(0.5)), AlphaOutcome::Unsupported);
}
/// **受控对照实验**(M116):同进程造三种窗口样式,逐行读 DWM 属性。
///
/// 为什么是 `#[ignore]`:它要真造窗口(CI 的无桌面环境里跑不出读数),所以只在需要出证据时
/// 手动跑:
///
/// ```text
/// cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture
/// ```
///
/// 输出就是报告里那张表(`| style | requested | exstyle | readback | … |`)。
/// **抓不住**:角上到底是透明还是方块(那要合成输出像素,本机 RDP 断连拿不到)。
#[cfg(target_os = "windows")]
#[test]
#[ignore = "要真窗口:手动跑 cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture"]
fn m116_corner_controlled_experiment() {
println!("{}", super::probe::TABLE_HEADER);
for row in super::controlled_experiment() {
println!("{}", row.describe());
}
}
/// **M116 实测踩到的判据错误**:`DwmGetWindowAttribute` 在本机(RDP 断连)回读偏好返回
/// `0x7FF8FFA9`——**正数**(严重性位为 0 = 成功),但 `hr == 0` 的写法会把它读成失败,
/// 于是「属性到底设上没有」变成猜谜。
///
/// 抓得住:把成功判据写回 `hr == 0`(那会让本机永远报「读不回来」)。
/// 抓不住:DWM 具体返回哪个信息码(那是运行期读数)。
#[test]
fn hresult_success_follows_the_severity_bit_not_equality_with_zero() {
use super::hresult_ok;
assert!(hresult_ok(0), "S_OK");
assert!(hresult_ok(1), "S_FALSE 也是成功");
// 失败码:严重性位为 1(i32 负数)。
assert!(!hresult_ok(0x8007_0057_u32 as i32), "E_INVALIDARG");
assert!(!hresult_ok(-1));
// **打印规则**:失败码必须印成 `hr as u32` 的原始位。
// `unsigned_abs()` 会把 `0x8007_0057` 印成 `0x7FF8_FFA9`(M116 实测踩过),
// 于是「E_INVALIDARG」读起来像「一个信息级成功码」,整个证据链就毁了。
let e_invalidarg = 0x8007_0057_u32 as i32;
assert_eq!(e_invalidarg as u32, 0x8007_0057);
assert_ne!(e_invalidarg.unsigned_abs(), 0x8007_0057);
}
/// **两种「错误号」不同源,打印方式也不同**(tower 裁定 2026-11-03:不许统一)。
///
/// - `GetLastError()` 是 **DWORD**:`87`(`ERROR_INVALID_PARAMETER`)/ `1400`
/// (`ERROR_INVALID_WINDOW_HANDLE`)这类小整数,没有严重性位,`set_layered` 用
/// `raw_os_error().unsigned_abs()` 取的就是它;
/// - `Dwm*WindowAttribute` 返回 **HRESULT**:成功看 bit 31,打印用 `as u32` 原始位。
///
/// 抓得住:有人把 `set_layered` 也改成 `as u32`(或把 DWM 那侧改成 `unsigned_abs`)——
/// 前者对 `87` 无害但对高位被置的错误号会印出负数,后者会把 `E_INVALIDARG` 印成
/// `0x7FF8FFA9`(M116 实测踩过的那个坑)。
#[test]
fn dword_error_codes_and_hresults_are_printed_differently() {
use super::hresult_ok;
// DWORD 侧:典型值是小的正数,两种打印结果一致(所以错误只会在高位情形暴露)。
for dword in [87_i32, 1400] {
assert_eq!(dword.unsigned_abs(), dword as u32);
assert!(
hresult_ok(dword),
"DWORD 不是 HRESULT,不该拿这个判据去读它"
);
}
// HRESULT 侧:失败码的原始位与 `unsigned_abs()` 必须不同。
let e_invalidarg = 0x8007_0057_u32 as i32;
assert!(!hresult_ok(e_invalidarg));
assert_eq!(e_invalidarg as u32, 0x8007_0057);
assert_ne!(e_invalidarg.unsigned_abs(), e_invalidarg as u32);
}
/// **产品日志行 `[corner] …` 的真实取值**(同一套判据 + 同一套格式化,句柄换成探针窗口)。
///
/// `excluded_reason=` / `frame=` / `layered=` 三栏是「为什么我们的窗口没有圆角」的唯一现场线索;
/// 报告里要贴真实取值,不是模板。跑法同 `m116`:
///
/// ```text
/// cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture
/// ```
#[cfg(target_os = "windows")]
#[test]
#[ignore = "要真窗口:手动跑 cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture"]
fn m116_corner_log_line_on_real_windows() {
use crate::platform::win32::corner::plan_corner;
use unlucky_presenter::prefs::WindowCorner;
let os = crate::platform::current_windows_version();
let plan = plan_corner(WindowCorner::System, os, crate::Backend::Gpu);
for (label, style, ex_style) in [
("decorated", super::probe::WS_OVERLAPPEDWINDOW, 0u32),
("popup", super::probe::WS_POPUP, 0u32),
("popup+layered", super::probe::WS_POPUP, 0x0008_0000),
] {
let hwnd = super::create_probe_window(style, ex_style);
if hwnd == 0 {
println!("[corner] {label} window=none");
continue;
}
super::show_probe_window(hwnd);
println!(
"[corner] {label} {} {} {}",
plan.log_line(WindowCorner::System),
super::apply_corner(hwnd, &plan).describe(),
super::dwm::style_summary(hwnd),
);
super::destroy_probe_window(hwnd);
}
// 设置里选「直角」时的取值(`excluded_reason` 必须变成 `user`)。
let square = plan_corner(WindowCorner::Square, os, crate::Backend::Gpu);
println!(
"[corner] policy=square {}",
square.log_line(WindowCorner::Square)
);
}
+14
View File
@@ -118,6 +118,20 @@ pub fn view_with_account<'a>(
/// 无边框窗口的「外壳壳子」:背景层 + 工作区 + 两条缩放热区 + 根容器底色。
///
/// ## 圆角生效时**不**内缩热区(M116)
///
/// Win11 / Server 2025 的 DWM 圆角是**合成层**的裁切:它只改「画出来的像素」,**不改窗口矩形**,
/// 也不改命中测试(`WM_NCHITTEST` / 我们的 `mouse_area` 走的仍是整窗矩形)。所以:
///
/// - 标题栏拖动面、三个 caption 键(46×48)、东/南两条 4px 缩放热区**都不动**;
/// - 角上那一小块(圆角弧以外的像素)在屏幕上看不见,但仍会响应——落在南侧热区里就是缩放、
/// 落在标题栏里就是拖动。两者都是无害的常规操作,比「内缩后角上出现 4px 死区」更好;
/// - 反过来**不能**把根容器改成圆角容器来「配合」外框:那只会在四角露出底色方块
/// (背景图/画布被裁成圆弧而外框仍然方),是自绘圆角的经典失败形态。
///
/// ⚠️ 上一条是本机**读不到**的部分:RDP 断连导致抓屏失效(见 `DevDocs/artifacts/m116/README.md`),
/// 「角上那块到底响不响应」没有任何像素证据,只有「DWM 不改窗口矩形」这条 API 事实。
/// 真机验收时请按 M116 报告里的清单补一条「在圆角窗口上拖边缩放仍命中」的手测。
fn window_frame<'a>(
ui: &'a Ui,
background: Option<Element<'a, Message>>,