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>>,
+17 -1
View File
@@ -15,6 +15,7 @@
//! | `appearance.window_opacity` | `0.08..=1.0` | `1.0` | 负责钳位;**不**调 FFI(落点在 `app-iced` 的 win32 层) |
//! | `appearance.titlebar_mode` | `auto` / `always` / `never` | `auto` | 只存值;`auto` 的判定属于 UI 侧(WCHROME-IMPL) |
//! | `appearance.palette_source` | `background-image` / `wallpaper` / `preset` | `preset` | 只存档位;三级编排在 `unlucky-design`(D38) |
//! | `appearance.window_corner` | `system` / `square` | `system` | 只存策略;「这台机器能不能圆」的判定在 `app-iced` 的 `platform`(M116) |
//!
//! 非法枚举**不报错**但也**不静默**:[`diagnostics`] 给出「读到什么 → 生效什么」的可读说明。
@@ -26,7 +27,8 @@ use super::store::SettingStore;
mod modes;
pub use modes::{
diagnostics, AppearanceDiagnostic, PaletteSource, TitlebarMode, PALETTE_SOURCES, TITLEBAR_MODES,
diagnostics, AppearanceDiagnostic, PaletteSource, TitlebarMode, WindowCorner, PALETTE_SOURCES,
TITLEBAR_MODES, WINDOW_CORNERS,
};
#[cfg(test)]
@@ -114,6 +116,11 @@ pub struct Appearance {
pub titlebar_mode: TitlebarMode,
/// 配色来源档(D38 三级;默认 [`PaletteSource::Preset`] = 内置色卡)。
pub palette_source: PaletteSource,
/// 窗口外框圆角策略(M116;默认 [`WindowCorner::System`] = 跟随系统)。
///
/// 只管用户要什么:真能不能圆由平台决定(Win11/Server 2025 走 DWM,Win10 保持直角),
/// 判定在 `unlucky-app-iced` 的 `platform`,core 不探测系统。
pub window_corner: WindowCorner,
}
impl Default for Appearance {
@@ -130,6 +137,7 @@ impl Default for Appearance {
window_opacity: 1.0,
titlebar_mode: TitlebarMode::Auto,
palette_source: PaletteSource::Preset,
window_corner: WindowCorner::System,
}
}
}
@@ -170,6 +178,10 @@ impl Appearance {
.map_or(default.palette_source, |value| {
PaletteSource::from_key_value(&value)
}),
window_corner: text("appearance.window_corner")
.map_or(default.window_corner, |value| {
WindowCorner::from_key_value(&value)
}),
}
}
@@ -241,6 +253,10 @@ impl Appearance {
"appearance.palette_source",
Value::from(self.palette_source.as_str()),
)?;
store.set(
"appearance.window_corner",
Value::from(self.window_corner.as_str()),
)?;
Ok(())
}
}
+57 -2
View File
@@ -10,6 +10,48 @@ use crate::setting::document::SettingDocument;
/// `appearance.titlebar_mode` 的允许值(D37 三态)。**顺序即设置页分段控件的顺序**。
pub const TITLEBAR_MODES: &[&str] = &["auto", "always", "never"];
/// `appearance.window_corner` 的允许值(M116)。**顺序即设置页分段控件的顺序**。
///
/// 只有两档是**刻意**的:系统不暴露圆角半径(Win32 没有「给我 DWM 的圆角像素值」这种 API),
/// 所以「固定 8/12/16」这类档位只能是自绘圆角,而自绘圆角在 `decorations=false` + 整窗 α 的
/// 窗口上必须裁内容层,会把标题栏三键与缩放热区一起裁掉。用户定案(2026-11-03):只留
/// 「跟随系统 / 直角」。
pub const WINDOW_CORNERS: &[&str] = &["system", "square"];
/// 窗口外框圆角策略(M116)。
///
/// 只表达**用户要什么**;「这台机器能做到什么」的判定在 UI 侧(`app-iced` 的 `platform`):
/// core 不探测系统能力(与 [`TitlebarMode`] 同一条纪律)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WindowCorner {
/// 跟随系统:Win11 / Server 2025 内核(build ≥ 22000)用 DWM 圆角,Win10 保持直角
#[default]
System,
/// 直角:任何平台都不加圆角
Square,
}
impl WindowCorner {
/// 落盘取值。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::System => "system",
Self::Square => "square",
}
}
/// 从落盘取值解析;未知值按 `system` 兜底(手改坏配置也不崩,回落原因由
/// [`diagnostics`] 给出可读诊断)。
#[must_use]
pub fn from_key_value(value: &str) -> Self {
match value {
"square" => Self::Square,
_ => Self::System,
}
}
}
/// 标题栏三态(D37)。
///
/// 只持有「用户想要哪种」,**不含** `auto` 的判定 —— 「非 Wayland → 显示 / 合成器名精确命中
@@ -111,8 +153,8 @@ pub struct AppearanceDiagnostic {
/// 读出需要提示的回落(空 = 一切正常)。
///
/// 只覆盖**枚举型**键(`titlebar_mode` / `palette_source`)。数值键(`window_opacity`)
/// 越界是「钳位且不报错」的既定语义(D36 下限 0.08),不算诊断项。
/// 只覆盖**枚举型**键(`titlebar_mode` / `palette_source` / `window_corner`)。数值键
/// (`window_opacity`)越界是「钳位且不报错」的既定语义(D36 下限 0.08),不算诊断项。
#[must_use]
pub fn diagnostics(doc: &SettingDocument) -> Vec<AppearanceDiagnostic> {
let mut found = Vec::new();
@@ -148,5 +190,18 @@ pub fn diagnostics(doc: &SettingDocument) -> Vec<AppearanceDiagnostic> {
});
}
}
if let Some(raw) = text("appearance.window_corner") {
if !WINDOW_CORNERS.contains(&raw.as_str()) {
let fallback = WindowCorner::System.as_str();
found.push(AppearanceDiagnostic {
key: "appearance.window_corner",
message: format!(
"appearance.window_corner 的值 {raw:?} 不在 {WINDOW_CORNERS:?} 之中,已按 {fallback} 处理"
),
raw,
fallback,
});
}
}
found
}
+39 -2
View File
@@ -6,8 +6,8 @@
use serde_json::json;
use super::{
diagnostics, Appearance, PaletteSource, TitlebarMode, PALETTE_SOURCES, TITLEBAR_MODES,
WINDOW_ALPHA_MIN,
diagnostics, Appearance, PaletteSource, TitlebarMode, WindowCorner, PALETTE_SOURCES,
TITLEBAR_MODES, WINDOW_ALPHA_MIN, WINDOW_CORNERS,
};
use crate::setting::document::SettingDocument;
use crate::setting::keys::launcher_schema;
@@ -176,3 +176,40 @@ fn older_document_loads_and_write_back_keeps_existing_keys() {
std::fs::remove_dir_all(&temp).ok();
}
/// **M116**:圆角键的三条读侧语义——缺键取默认、合法值往返、坏值落回 `system` 并**留诊断**。
///
/// 抓得住:把 `from_key_value` 的兜底写成 `Square`(那会让「配置写坏了」变成「用户想要直角」)、
/// 或者漏掉 `diagnostics` 里那一条(静默回落)。
/// 抓不住:这台机器能不能真圆(那是 UI 侧 `platform::plan_corner` 的事)。
#[test]
fn window_corner_falls_back_to_follow_system_and_says_so() {
let appearance = Appearance::read(&doc(json!({})));
assert_eq!(
appearance.window_corner,
WindowCorner::System,
"缺键 = 出厂 = 跟随系统"
);
assert!(
diagnostics(&doc(json!({}))).is_empty(),
"缺键不是回落,不该有诊断"
);
for corner in [WindowCorner::System, WindowCorner::Square] {
assert_eq!(WindowCorner::from_key_value(corner.as_str()), corner);
assert!(WINDOW_CORNERS.contains(&corner.as_str()));
let read = Appearance::read(&doc(
json!({ "appearance": { "window_corner": corner.as_str() } }),
));
assert_eq!(read.window_corner, corner);
}
// 坏值:不报错、不崩,落到 system,但**必须**留下可读诊断。
let bad = doc(json!({ "appearance": { "window_corner": "rounded" } }));
assert_eq!(Appearance::read(&bad).window_corner, WindowCorner::System);
let notes = diagnostics(&bad);
assert_eq!(notes.len(), 1, "{notes:?}");
assert_eq!(notes[0].key, "appearance.window_corner");
assert_eq!(notes[0].fallback, "system");
assert!(notes[0].message.contains("rounded"), "{}", notes[0].message);
}
+18 -1
View File
@@ -52,9 +52,16 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"];
/// `1.4.0` 登记 P4 的外观下层三键(`appearance.window_opacity` / `titlebar_mode` /
/// `palette_source`,M91 · D36/D37/D38):仍然只推 minor、不加迁移步骤——三者的出厂值
/// (不透明 / auto / 内置色卡)正是「老文件没有这些键」时的等价物。
/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):
/// 同一套纯增量语义——老文件缺该键 ⇒ 读默认 `system` ⇒ 跟随系统,与写进文件后行为一致。
/// **向前兼容(旧版本读新文件)**:本仓的 [`super::catalog`] 是白名单校验,
/// 但读路径 (`SettingDocument::load`) 只**登记**陌生键、不报错也不删除(`RenderMode::Full`
/// 会把它们原样写回),所以旧版本打开新文件不会崩,只是看不到「直角」这一档;
/// 只有**显式 `store.set("appearance.window_corner", …)`** 在旧版本上才会
/// 返回 `SettingError::UnknownKey`(新版本自己不会这么调)。
#[must_use]
pub fn launcher_version() -> SchemaVersion {
SchemaVersion::new(1, 4, 0)
SchemaVersion::new(1, 5, 0)
}
/// `config/launcher.json` 的 schema。
@@ -336,6 +343,16 @@ pub fn launcher_schema() -> SettingSchema {
AiPolicy::audited(),
"配色来源档(D38 三级):background-image(背景图取色)/ wallpaper(桌面壁纸取色)/ preset(内置色卡,默认)",
))
// M116:窗口外框圆角策略。只有两档(system / square)——系统不暴露圆角半径,
// 「固定 8/12/16」只能是自绘圆角,而自绘圆角在无边框 + 整窗 α 上要裁内容层,
// 会连标题栏三键与缩放热区一起裁掉(用户定案 2026-11-03)。
.with(SettingSpec::enumeration(
"appearance.window_corner",
"system",
super::appearance::WINDOW_CORNERS,
AiPolicy::audited(),
"窗口圆角:system(跟随系统:Win11/Server 2025 走 DWM,Win10 保持直角)/ square(总是直角),默认 system",
))
// 已废弃:D17 定案 appearance.theme_mode 为准(旧值读兼容映射,写永远走新键)。
.with(
SettingSpec::new(
+5 -1
View File
@@ -129,7 +129,11 @@ fn appearance_keys_are_all_ai_writable_and_registered() {
#[test]
fn schema_registration_shape_is_right() {
assert_eq!(launcher_version().minor, 4, "1.4.0 登记外观下层三键");
assert_eq!(
launcher_version().minor,
5,
"1.5.0 登记 M116 的窗口圆角策略键"
);
let schema = launcher_schema();
assert_eq!(
schema.spec("appearance.bg_fill").unwrap().kind,
+3 -2
View File
@@ -128,8 +128,9 @@ fn overrides_key_is_an_enum_whitelist() {
#[test]
fn schema_versions_register_the_new_keys_without_migration() {
// 1.4.0 登记 M91 的外观下层三键(1.3.0 是 D17 的「外观(高级)」键,1.2.0 是 D23 的 JDK 落点/优先级键)。
assert_eq!(launcher_version(), SchemaVersion::new(1, 4, 0));
// 1.5.0 登记 M116 的窗口圆角策略键(1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,
// 1.2.0 = D23 的 JDK 落点/优先级键)。
assert_eq!(launcher_version(), SchemaVersion::new(1, 5, 0));
assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0));
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
@@ -66,7 +66,7 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() {
#[test]
fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() {
assert_eq!(launcher_version(), SchemaVersion::new(1, 4, 0));
assert_eq!(launcher_version(), SchemaVersion::new(1, 5, 0));
assert_eq!(
launcher_version().major,
1,
+11
View File
@@ -13,6 +13,17 @@
//!
//! 一个元素该查哪一层由 [`radius_layer_for`] 判定(按尺寸,唯一入口);微控件层取值走
//! [`ComponentRadius::radius_px`],**尺寸超界时返回 `None`**——这不是文档约定,是签名保证。
//!
//! ## 窗口**外框**圆角不在这里(M116)
//!
//! `ShapeScale` 管的是**我们画的东西**:卡片、按钮、浮层、面板。窗口的最外层圆角由操作系统
//! 决定(Win11 / Server 2025 走 DWM 合成器,macOS 走 `layer.cornerRadius`,X11/Wayland 看合成器),
//! 系统**不暴露**那个半径值,我们也不自己定一个数字(用户定案:不做 8/12/16 档)。
//! 所以窗口圆角没有令牌:判据在 `unlucky-app-iced` 的 `platform::plan_corner`,
//! 落点在 `platform/win32/transparency`。
//!
//! 反过来说:**别**为了「让窗口看起来是圆的」去给根容器加 `ShapeScale::ExtraLarge`——那会把
//! 四角裁掉内容(背景图/标题栏底色露出方角),而外框仍然是方的。
use crate::motion::Spring;
+4
View File
@@ -57,6 +57,10 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("FontCjkReady", "Chinese font ready: {family}", "中文字体已就绪:{family}"),
("SettingsLanguage", "Language", "界面语言"),
("SettingsLanguageHint", "Switches the interface language. Chinese text needs a Chinese font.", "切换界面语言。显示中文需要中文字体。"),
("SettingsAppearanceCorner", "Window corners", "窗口圆角"),
("SettingsAppearanceCornerSystem", "Follow system", "跟随系统"),
("SettingsAppearanceCornerSquare", "Square", "直角"),
("SettingsAppearanceCornerHint", "Windows 11 and Windows Server 2025 round the window through the compositor, so the edges stay smooth. Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply, and we do not draw our own corners.", "Windows 11 与 Windows Server 2025 由系统合成器画圆角,边缘平滑;Windows 10 保持直角;macOS 与 Linux 交给桌面自己的规则,我们不自己画圆角。"),
("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"),
("DownloadTitle", "Version management", "版本管理"),
("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"),
+22 -3
View File
@@ -1,11 +1,30 @@
//! 设置页「Appearance」分组文案:**预留**(真控件在 `pages/appearance/section.rs`,文案还没接 i18n)。
//! 设置页「Appearance」分组文案(M116 首次落地:窗口圆角一档)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。
//!
//! ## 为什么只有圆角这一组的 key
//!
//! 本分组的既有控件(整窗/面板透明度、背景图、配色来源……)是 M93 落的,文案是英文基线字面量
//! (`pages/appearance/section.rs`)。M116 新增的圆角档位**从一开始就走词表**(tower 口径:
//! 设置项文案不许硬编码),所以这里先只有它这一组;老控件接词表归 i18n 接线 mission。
//!
//! ## 文案口径(不许暗示所有平台一致)
//!
//! 用户定案:Win11 / Server 2025 开系统圆角,Win10 可以不开,其他系统按窗口合成器的默认规则。
//! 所以说明句必须写清「谁负责画圆」而不是「我们支持圆角」——后半句在 Win10 上是假的。
macro_rules! settings_appearance_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* { $($acc)* } }
$next! { $($rest,)* {
$($acc)*
SettingsAppearanceCorner => ("Window corners", "窗口圆角"),
SettingsAppearanceCornerSystem => ("Follow system", "跟随系统"),
SettingsAppearanceCornerSquare => ("Square", "直角"),
SettingsAppearanceCornerHint => (
"Windows 11 and Windows Server 2025 round the window through the compositor, so the edges stay smooth. Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply, and we do not draw our own corners.",
"Windows 11 与 Windows Server 2025 由系统合成器画圆角,边缘平滑;Windows 10 保持直角;macOS 与 Linux 交给桌面自己的规则,我们不自己画圆角。"
),
} }
};
}
+21 -3
View File
@@ -17,7 +17,18 @@ pub use unlucky_core::setting::appearance::PALETTE_SOURCES;
/// 同理 [`PANEL_ALPHA_MIN`](0.4)与 [`BG_DIM_MAX`](0.95)也在这里重导出:M93 的 UI 侧要按它们
/// 钳位滑块与暗化,而 `app-iced` 不直接依赖 core——不重导出就只能各抄一份字面量。
pub use unlucky_core::setting::appearance::{
PaletteSource, TitlebarMode, BG_DIM_MAX, PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN,
PaletteSource, TitlebarMode, WindowCorner, BG_DIM_MAX, PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN,
WINDOW_CORNERS,
};
/// 系统版本探测(M116):Windows 侧「能不能用 DWM 圆角」的判据要 build 号,而
/// `unlucky-app-iced` 不直接依赖 `unlucky-core`(分层:UI → presenter → core),
/// 所以在这里做**唯一一次**重导出,`platform` 只认 [`WindowsVersion`]。
///
/// [`WIN11_MIN_BUILD`] = 22000:Win11 与 Server 2025 共用同一内核判据(M116 实测本机
/// Server 2025 build 26100 落在这条线之上)。
pub use unlucky_core::platform::windows_version::{
probe as probe_windows_version, WindowsVersion, WIN11_MIN_BUILD,
};
use crate::dto::VersionCategory;
@@ -186,16 +197,19 @@ pub struct AppearanceBase {
pub titlebar_mode: TitlebarMode,
/// 配色来源档(三级编排在 `unlucky-design`,本 crate 只给值)
pub palette_source: PaletteSource,
/// 窗口外框圆角策略(M116:`system` / `square`;能不能圆由平台决定)
pub window_corner: WindowCorner,
}
impl AppearanceBase {
/// 出厂态:不透明 / `auto` / 内置色卡。
/// 出厂态:不透明 / `auto` / 内置色卡 / 跟随系统圆角。
#[must_use]
pub fn factory() -> Self {
Self {
window_opacity: 1.0,
titlebar_mode: TitlebarMode::Auto,
palette_source: PaletteSource::Preset,
window_corner: WindowCorner::System,
}
}
@@ -209,6 +223,7 @@ impl AppearanceBase {
window_opacity: appearance.window_opacity as f32,
titlebar_mode: appearance.titlebar_mode,
palette_source: appearance.palette_source,
window_corner: appearance.window_corner,
}
}
@@ -289,9 +304,11 @@ pub mod keys {
pub const TITLEBAR_MODE: &str = "appearance.titlebar_mode";
/// 配色来源档(D38)
pub const PALETTE_SOURCE: &str = "appearance.palette_source";
/// 窗口圆角策略(M116;`system` / `square`)
pub const WINDOW_CORNER: &str = "appearance.window_corner";
/// 本模块登记的全部键(自检/迁移用)。
pub const ALL: [&str; 16] = [
pub const ALL: [&str; 17] = [
NAV_COLLAPSED,
AI_COLLAPSED,
TILE_SIZE,
@@ -308,6 +325,7 @@ pub mod keys {
WINDOW_OPACITY,
TITLEBAR_MODE,
PALETTE_SOURCE,
WINDOW_CORNER,
];
}
+20 -1
View File
@@ -2,7 +2,7 @@
use super::{
keys, AppearanceBase, InstanceView, PaletteSource, ThemeMode, TileSize, TitlebarMode, UiPrefs,
WINDOW_ALPHA_MIN,
WindowCorner, WINDOW_ALPHA_MIN,
};
#[test]
@@ -88,3 +88,22 @@ fn appearance_base_illegal_enums_fall_back_and_legal_ones_read_through() {
assert_eq!(good.titlebar_mode, TitlebarMode::Never);
assert_eq!(good.palette_source, PaletteSource::Wallpaper);
}
/// **M116**:`appearance.window_corner` 的读侧——缺键 = 跟随系统、写 `square` 读回 `square`。
///
/// 抓得住:`AppearanceBase::from_document` 漏搬这一格(那会让设置页永远显示「跟随系统」,
/// 用户点了直角重启后又变回去)。
/// 抓不住:这台机器能不能真圆(`app-iced` 的 `platform::plan_corner` 管)。
#[test]
fn window_corner_round_trips_through_the_document() {
assert_eq!(
base_of(serde_json::json!({})).window_corner,
WindowCorner::System,
"缺键 → 出厂跟随系统"
);
let square = base_of(serde_json::json!({ "appearance": { "window_corner": "square" } }));
assert_eq!(square.window_corner, WindowCorner::Square);
// 坏值:core 侧落到 system(并留诊断),这里只钉 UI 侧看到的值。
let broken = base_of(serde_json::json!({ "appearance": { "window_corner": "rounded" } }));
assert_eq!(broken.window_corner, WindowCorner::System);
}