diff --git a/crates/app-iced/src/app/appearance.rs b/crates/app-iced/src/app/appearance.rs index e3e59e8..68fe4c7 100644 --- a/crates/app-iced/src/app/appearance.rs +++ b/crates/app-iced/src/app/appearance.rs @@ -5,16 +5,18 @@ //! | 动作 | 唯一落点 | 为什么 | //! |---|---|---| //! | 下发整窗 α | [`App::apply_window_alpha`] → `app::window_alpha::dispatch` | 判据在 `platform`、FFI 在 `platform/win32`,这里只做「取 α → 交给执行侧 → 记日志」 | -//! | 写回配置 | [`App::persist_appearance`](8 个 UI 字段)+ `AppCommand::SetSetting`(另外两个只读键) | 每个键**只有一个写者**:`AppearancePrefs` 里没有 `window_opacity` / `palette_source`,它们只能走 schema 指令 | +//! | 写回配置 | [`App::persist_appearance`](8 个 UI 字段)+ `AppCommand::SetSetting`(另外**三个**只读键) | 每个键**只有一个写者**:`AppearancePrefs` 里没有 `window_opacity` / `palette_source` / `window_corner`,它们只能走 schema 指令 | //! | 取证日志 | [`App::log_appearance`] | 一行里同时有请求值、生效值、下发字节与 FFI 结局 | //! //! ## 为什么「写回」要分两条路 //! //! `SettingsBridge::persist` 收的是 `AppearancePrefs`(背景图四件 + 面板 α + 玻璃 + 强调色), //! 它的实现是「先读文件当前值,再覆盖我们改的那几个字段」——所以用它写**它覆盖的键**最省事, -//! 也不会把用户的自定义强调色冲掉。而 `appearance.window_opacity` / `appearance.palette_source` -//! 不在那个结构里(它们是「只读下层键」,见 `presenter::prefs::AppearanceBase`), +//! 也不会把用户的自定义强调色冲掉。而 `appearance.window_opacity` / `appearance.palette_source` / +//! `appearance.window_corner` 不在那个结构里(它们是「只读下层键」,见 +//! `presenter::prefs::AppearanceBase` 的**四个**字段:整窗 α / 标题栏三态 / 配色来源 / 窗口圆角), //! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。 +//! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。 use unlucky_presenter::dto::AppCommand; @@ -65,8 +67,14 @@ impl App { self.refresh_view(); match change.key() { - // 两个只读下层键:走 schema 指令(`SettingsBridge` 的结构里没有它们)。 - Some(_) if matches!(change, Change::WindowOpacity(_) | Change::PaletteSource(_)) => { + // 三个只读下层键(整窗 α / 配色来源 / 窗口圆角):走 schema 指令 + // (`SettingsBridge` 的结构里没有它们;标题栏三态还没有 UI 写者,不在此列)。 + 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 +92,8 @@ impl App { } else { self.log_appearance("change", None); } + // M116:圆角档位变了要**重新判定 + 重新下发**(与整窗 α 是两条独立的管线)。 + self.apply_window_corner("change"); } /// 重读配置后同步外观(热切:外部 CLI / AI 改完 1 秒内生效)。 @@ -95,6 +105,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 管的字段,强调色/玻璃原样保留)。 diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 8de1625..ea33fc0 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -156,6 +156,11 @@ pub struct App { pub(super) account: pages::account::Page, /// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。 pub(super) avatars: pages::account::avatar::Cache, + /// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。 + /// + /// `None` = 还没下发过(首屏 `boot` 那一次)。指纹类型见 + /// [`crate::platform::win32::corner::CornerApplied`]。 + pub(super) corner_applied: Option, } impl App { @@ -188,8 +193,8 @@ impl App { let width = WINDOW_SIZE.width; let height = WINDOW_SIZE.height; let theme_mode = bridge.load().1; - // 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 3 个只读下层键 - // (整窗 α / 标题栏三态 / 配色来源)。M93 之前这两个键在 app 侧**根本读不到**。 + // 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 4 个只读下层键 + // (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。M93 之前这些键在 app 侧**根本读不到**。 // // 截图通道**不读用户配置**(D30:同一份代码在两台机器上要出同一张图):外观一律取出厂值, // 矩阵需要的值由 `ULL_APPEARANCE_*` 显式灌进来。 @@ -266,6 +271,7 @@ impl App { width, ), avatars: pages::account::avatar::Cache::default(), + corner_applied: None, }; // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 app.rebuild_ui(); diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index c498a3a..ed33e8f 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -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) => { diff --git a/crates/app-iced/src/app/update/appearance.rs b/crates/app-iced/src/app/update/appearance.rs new file mode 100644 index 0000000..e95f6f1 --- /dev/null +++ b/crates/app-iced/src/app/update/appearance.rs @@ -0,0 +1,81 @@ +//! 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` 两栏则是 +//! 「圆角为什么不生效」的两个现场读数:`frame=`(窗口形状)与 `layered=`(整窗 α 引入的分层位)。 +//! +//! ⚠️ **产品窗口不是 `WS_POPUP`**(M116 r4 复审订正):winit 在 `decorations = false` 下给的是 +//! `WS_CAPTION|WS_BORDER|WS_CLIPSIBLINGS|WS_SYSMENU`(可缩放时再加 `WS_THICKFRAME` 与 min/max 键), +//! `WS_POPUP` 只在**带 owner** 的弹出窗才加;无边框观感来自 `WM_NCCALCSIZE` 把客户区铺满。 +//! 所以真进程日志里 `frame=` 应是 `overlapped`(关掉 min/max 则 `other`),**永远不会是 `popup`**。 +//! 真正让系统忽略圆角请求的是**整窗 α 补的 `WS_EX_LAYERED`**(`layered=on`)。 +//! +//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字 +//! (M116 取消了 8/12/16 固定档)。 + +use crate::platform; +use crate::platform::win32::corner::{self, CornerApplied}; +use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome}; + +use super::super::state::App; + +impl App { + /// 把当前圆角策略下发到窗口,并打一行可分辨的日志。 + /// + /// ## 去重(M116 r1 复审 P2-4) + /// + /// 设置页的滑条(整窗 α / 面板 α / 暗化)每动一下都会走一次外观变更,而圆角与那些键无关。 + /// 判定完全没变时这里**直接返回**:不动窗口、不打日志。判据是 + /// [`corner::needs_redispatch`](纯函数,有「同一策略 N 次只记 1 行」的测试), + /// 指纹只含「策略 / 来源 / 原因码 / 有没有句柄」,不含只有真下发才拿得到的读数。 + /// + /// 没有句柄时**也打日志**(`window=none`):不能让「没生效」只体现为屏幕上没变化。 + /// 句柄从无到有会让指纹变化 ⇒ 首次拿到 HWND 时必然重下一次。 + pub(crate) fn apply_window_corner(&mut self, context: &str) { + let policy = self.appearance.window_corner(); + let plan = platform::plan_corner( + policy, + platform::current_windows_version(), + self.appearance.backend, + ); + // 整窗 α < 1 ⇒ `window_alpha::dispatch` 会给窗口补 `WS_EX_LAYERED`;系统因此忽略圆角请求 + // (tower 裁定 A / D47)。这里只记**事实**:α 是否把我们自己的分层位打开了。 + let alpha_layered = self.appearance.window_opacity < 1.0; + let suppressed_by = alpha_layered.then_some("window_alpha"); + let next = CornerApplied::new(policy, &plan, self.hwnd.is_some(), alpha_layered); + if !corner::needs_redispatch(self.corner_applied.as_ref(), &next) { + return; + } + self.corner_applied = Some(next); + + // 行的形状只有一处(`corner::format_line`),因为那里能在没有窗口的环境里被单测钉住。 + // 关键的三栏必须在同一行:`pref=`(请求发出去了)、`layered=`(分层位在不在,来自 + // 整窗 α)、`frame=`(窗口形状 = `overlapped`,不是 `popup`)——否则以后只能猜是哪一条。 + 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!( + "{}", + corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by) + ); + } +} diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 4ce2137..1cbda1c 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -39,8 +39,26 @@ //! ## 取证 //! //! 每一处改动都打一行 [`state::State::log_line`]:`[appearance] window_alpha=… panel_alpha=… -//! byte=…`。数值是**下发前**的字节(不是观感),因为在 RDP + 虚拟显示适配器上「看上去多透明」 -//! 不可信。截图矩阵用 `ULL_APPEARANCE_*` 环境变量灌值(见 [`state`] 的常量)。 +//! byte=… corner=…`;圆角另有一条独立管线,行形如 +//! `[corner] corner=policy=system source=dwm os=10.0.26100 excluded_reason=none +//! radius=system pref=2 readback=? read_hr=0x80070057 frame=overlapped layered=on +//! suppressed_by=window_alpha`(真进程实跑的两条见 `DevDocs/artifacts/m116/README.md`; +//! `frame=` 恒为 `overlapped`/`other`,**不会是 `popup`**) +//! (见 `app::update::appearance` 与 `platform::win32::corner::format_line`)。数值是**下发前**的 +//! 字节/属性值(不是观感),因为在 RDP + 虚拟显示适配器上「看上去多透明 / 圆不圆」不可信。 +//! 截图矩阵用 `ULL_APPEARANCE_*` 环境变量灌值(见 [`state`] 的常量)。 + +use iced::widget::container; +use iced::{Background, Border, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::pages::frame; +use crate::pages::Message; +use crate::theme::Ui; pub mod alpha; pub mod background; @@ -50,6 +68,31 @@ mod picture; mod section; mod state; +/// 状态提示的 **tonal notice**(`section.rs` 的 400 行硬顶把它放在模块根)。 +/// +/// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。 +/// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开 +/// (不靠色相、不新增令牌)。调用点见 [`section::section`] 的圆角一节。 +fn notice<'a>(ui: &'a Ui, text: &str) -> Element<'a, Message> { + let background = ui.color(Role::SurfaceContainerHighest); + let tone = ui.color(Role::OnSurfaceVariant); + let body = frame::text_role_owned(ui, TypeRole::BodySmall, true, text.to_owned(), tone); + let pad = ui.space(Space::X2); + let radius = ui.radius_dp(ShapeScale::Small); + container(body) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X1), pad])) + .style(move |_theme| container::Style { + background: Some(Background::Color(background)), + border: Border { + radius, + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + pub use picture::layer; pub use section::section; pub use state::State; @@ -80,6 +123,8 @@ pub enum Change { BgDim(f32), /// 配色来源档。 PaletteSource(unlucky_presenter::prefs::PaletteSource), + /// 窗口圆角档位(M116:跟随系统 / 直角)。 + Corner(unlucky_presenter::prefs::WindowCorner), /// 设置页切分组(左列点选)。 SelectGroup(usize), } @@ -96,6 +141,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 +159,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 +186,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:?}"); diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs index 9c87cac..04e3ada 100644 --- a/crates/app-iced/src/pages/appearance/section.rs +++ b/crates/app-iced/src/pages/appearance/section.rs @@ -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`] | @@ -19,7 +20,7 @@ //! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写 //! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。 -use iced::widget::{button, column, container, row}; +use iced::widget::{button, column, container, row, space}; use iced::{ Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, }; @@ -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,37 @@ 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), + ), + ], + )); + // **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。 + // 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。 + if corner == WindowCorner::System && state.window_opacity < 1.0 { + rows = rows.push(super::notice(ui, t(Key::SettingsAppearanceCornerSuspended))); + let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into(); + rows = rows.push(gap); + } + let tone = ui.color(Role::OnSurfaceVariant); + rows = rows.push(note_line(ui, t(Key::SettingsAppearanceCornerHint), tone)); + + // ③ 面板透明度(CPU 档强制 1.0) let forced = state.panel_is_forced(); let panel_percent = if forced { format!("100% (forced from {:.2})", state.panel_alpha) @@ -96,7 +128,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 +138,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 +152,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 +172,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 +198,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ], )); - // ⑦ 暗化 + // ⑧ 暗化 rows = rows.push(slider_row( ui, "Dim", @@ -181,7 +213,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ), )); - // ⑧ 配色来源 + // ⑨ 配色来源 rows = rows.push(choice_row( ui, "Colour source", @@ -238,6 +270,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) diff --git a/crates/app-iced/src/pages/appearance/state.rs b/crates/app-iced/src/pages/appearance/state.rs index cfd5c00..e1868bf 100644 --- a/crates/app-iced/src/pages/appearance/state.rs +++ b/crates/app-iced/src/pages/appearance/state.rs @@ -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; diff --git a/crates/app-iced/src/pages/settings/appearance.rs b/crates/app-iced/src/pages/settings/appearance.rs index a58dbed..6d98023 100644 --- a/crates/app-iced/src/pages/settings/appearance.rs +++ b/crates/app-iced/src/pages/settings/appearance.rs @@ -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%"), ], diff --git a/crates/app-iced/src/pages/settings/tests/appearance_section.rs b/crates/app-iced/src/pages/settings/tests/appearance_section.rs index c32efa6..e318042 100644 --- a/crates/app-iced/src/pages/settings/tests/appearance_section.rs +++ b/crates/app-iced/src/pages/settings/tests/appearance_section.rs @@ -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%"), ] diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index 3c37f98..e4e1e3f 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -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 { + static CACHED: std::sync::OnceLock> = + std::sync::OnceLock::new(); + *CACHED.get_or_init(unlucky_presenter::prefs::probe_windows_version) +} + /// 用户请求的整窗 α → 真正写进窗口的值(纯函数)。 /// /// - `requested <= 0.0`(含 `-0.0`)→ `None`:**拒绝整窗穿透**。α = 0 表示窗口内容全透明, diff --git a/crates/app-iced/src/platform/win32/corner.rs b/crates/app-iced/src/platform/win32/corner.rs new file mode 100644 index 0000000..b4a8c87 --- /dev/null +++ b/crates/app-iced/src/platform/win32/corner.rs @@ -0,0 +1,293 @@ +//! M116:窗口外框圆角的**判据**(纯函数,零 `unsafe`、零 FFI、零环境读取)。 +//! +//! ## 判据为什么单独拎出来 +//! +//! 「能不能圆」= 用户策略 × 系统版本 × 渲染档,这三件事**可以被断言证明**;而 DWM 的**视觉** +//! 结果在本仓库的验证环境(RDP 断连、抓屏失效)上没有参考价值。把可测的那一半独立出来, +//! 才有「有测试的判据 + 没测试的观感」这条诚实的分界(与 [`super::super::effective_window_alpha`] +//! 同一套分法)。 +//! +//! 真正下发给窗口的动作在 [`super::transparency`](全仓唯一 `unsafe` 落点)。 +//! +//! ## 平台矩阵(哪些是实测、哪些是推断) +//! +//! | 平台 | 我们做什么 | 依据级别 | +//! |---|---|---| +//! | Win11 / Server 2025(build ≥ 22000) | 下发 `DWMWCP_ROUND`(**请求**系统圆角) | 写入被接受 = **本机 API 实测**;「圆没圆 / 边缘什么样」= **未验**(无像素、回读不可用) | +//! | Win10 及更早(build < 22000) | 直角;不做 `SetWindowRgn` 毛边默认 | 「该属性不存在」= **外部文档证据**;本机没有 Win10 | +//! | macOS | 不主动加,交给系统 | **推断**(未真机验) | +//! | Linux X11 / Wayland | 不承诺,直角即如实结果 | **推断**(未真机验) | +//! +//! ⚠️ **「Win11 上我们的窗口一定有圆角、边缘平滑」这句话本分支的证据不支持**,别写回去。 +//! 本机(Server 2025 build 26100、RDP 会话断开)测到的是: +//! ① `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 被接受(三种窗口样式都接受); +//! ② `DwmGetWindowAttribute` 同一属性返回 `E_INVALIDARG`(本会话不支持回读,与窗口形状无关); +//! ③ DWM 的调整带只给**有 `WS_CAPTION|WS_THICKFRAME` 的窗口**:`EXTENDED_FRAME_BOUNDS − +//! GetWindowRect` 在有边框窗口上是 `+0,−7`,在**裸 `WS_POPUP` 假形状**上是 `+0,+0`。 +//! +//! ⚠️ **第 ③ 条不能拿裸 popup 的读数去解释产品窗口**(M116 r4 复审订正):产品窗口 +//! **不是** `WS_POPUP`(winit 只在带 owner 的弹出窗才加它;无边框观感来自 `WM_NCCALCSIZE`), +//! 它带 `WS_CAPTION|WS_THICKFRAME` ⇒ 按同一张表应得 `+0,−7`,与 `decorated` 同族。 +//! 探针里 `product` / `product+alpha` 两行量的就是这个真实形状。 +//! +//! ④ **整窗 α 会关掉圆角**(tower 裁定 A / D47:整窗透明度优先于 DWM 圆角):α < 1 时我们补 +//! `WS_EX_LAYERED`,此时系统会忽略圆角请求——请求照样发出(日志里 `pref=` 有值), +//! 只是不生效;α 回到 1.0 就恢复。日志用 `suppressed_by=window_alpha` 记这一条**观测事实**。 +//! +//! 「圆角长什么样」归真机验收,证据目录见 `DevDocs/artifacts/m116/`。 +//! +//! 后两行在本 crate 里落到同一格:探测不到 Windows 版本 ⇒ `UnknownOs` ⇒ 直角。 +//! 这是「不承诺」,不是「做不到」——两者的区别写在日志的原因码里。 + +use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD}; + +use crate::Backend; + +/// 一次「圆角该从哪来」的判定结果(M116)。 +/// +/// 只有两种来源,且**互相排斥**: +/// +/// | 来源 | 谁画圆角 | 我们做什么 | +/// |---|---|---| +/// | [`CornerSource::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, + /// 探测到的 Windows 版本(`None` = 不是 Windows / 读不到)。 + pub os: Option, +} + +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 圆角(**tower 2026-11-03 裁定,按此执行**): +/// D36 的 CPU 档约束针对的是**我们自己的渲染路径**(面板 α / 玻璃 / 模糊要关、不许拿透明叠加 +/// 去换效果),而 DWM 圆角是**合成器侧**的效果,不增加我们的 GPU/CPU 绘制负担。所以 +/// 「CPU 档关掉 Win11 圆角」不是 D36 的要求,而是凭空造一个没人要的降级。 +/// +/// 反过来在非 DWM 平台上,圆角只剩「自绘裁切 / `SetWindowRgn` 毛边」这两条**我们自己的** +/// 合成技巧,那才是 D36 要拦的东西 ⇒ 那一格落 `cpu-tier` 而不是 `os-build` +/// (同一个直角,**原因不同**,日志里可分辨)。 +#[must_use] +pub fn plan_corner( + policy: WindowCorner, + os: Option, + 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] corner=policy=system source=dwm os=10.0.26100 excluded_reason=none \ +/// radius=system pref=2 readback=? read_hr=0x80070057 frame=overlapped layered=on \ +/// suppressed_by=window_alpha +/// ``` +/// +/// 为什么单独拎出来:`App::apply_window_corner` 要真句柄、真 DWM 才能跑,而**这一行的形状** +/// 恰恰是「以后分辨没生效 / 生效了看不见」的唯一依据(M116 任务书原话)。形状必须在没有窗口的 +/// 环境里也能被断言——tower 2026-11-03 明确要求:不为了这行日志去构建 bin。 +/// +/// 参数里 `outcome` / `style` 由调用方给(`CornerOutcome::describe()` / `style_summary(hwnd)`), +/// 本文件不依赖 FFI 层,因此可以在非 Windows 目标上编、也能被单测覆盖。 +/// +/// `suppressed_by` 是**观测事实**(tower 裁定 A / D47 要求的那一栏):整窗 α < 1 时我们补了 +/// `WS_EX_LAYERED`,此时系统忽略圆角请求。配合同一行里的 `pref=`(请求确实发出去了)与 +/// `layered=`(分层位确实在),读者能自己推出「不是我们没发请求」——本函数不写结论性措辞。 +#[must_use] +pub fn format_line( + context: &str, + plan: &CornerPlan, + policy: WindowCorner, + outcome: &str, + style: &str, + suppressed_by: Option<&'static str>, +) -> String { + let mut line = format!( + "[corner] {context} {} {outcome} {style}", + plan.log_line(policy) + ); + if let Some(reason) = suppressed_by { + line.push(' '); + line.push_str("suppressed_by="); + line.push_str(reason); + } + line +} + +/// 一次「已经下发过」的**指纹**:只由判定本身与「当时有没有窗口」组成,不含读数。 +/// +/// 为什么需要它:设置页的滑条(整窗 α / 面板 α / 暗化)每动一下就触发一次外部外观变更, +/// 而圆角与那些键**无关**。没有去重时,拖一次滑条就会刷出一串 +/// `[corner] change …`,把「分辨有没有生效」的那条日志淹掉(M116 r1 复审 P2-4)。 +/// +/// 指纹**刻意不含 `pref=` / `readback=` 这类读数**:那些只有真下发才知道,拿它们参与比较 +/// 会变成「自己比较自己」。判定没变就不重下、也不重记——这也是「同一策略 N 次只记 1 行」的判据。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CornerApplied { + /// 当时的策略(`system` / `square`)。 + pub policy: WindowCorner, + /// 判定出来的来源。 + pub source: CornerSource, + /// 直角时的原因码(`None` = 走 DWM)。 + pub excluded: Option, + /// 下发时**有没有窗口句柄**:`false → true` 必须重下(首次拿到 HWND 时才真能设属性)。 + pub has_window: bool, + /// 整窗 α 是否 < 1(= 我们补了 `WS_EX_LAYERED`,系统会忽略圆角请求)。 + /// + /// 进指纹是为了让「α 从 1.0 拉低 / 拉回 1.0」各触发一次重下与一行日志——那两次的 + /// 系统行为确实不同,不重记就看不出来。 + pub suppressed_by_window_alpha: bool, +} + +impl CornerApplied { + /// 由判定 + 「有没有句柄」+ 「整窗 α 是否补了分层」构造指纹。 + #[must_use] + pub const fn new( + policy: WindowCorner, + plan: &CornerPlan, + has_window: bool, + suppressed_by_window_alpha: bool, + ) -> Self { + Self { + policy, + source: plan.source, + excluded: plan.excluded, + has_window, + suppressed_by_window_alpha, + } + } +} + +/// 这一次要不要真的下发 / 记日志(纯函数)。 +/// +/// `false` = 判定与上次完全一致(策略、来源、原因码、有没有句柄都没变)⇒ **不动窗口、不打日志**。 +/// `true` 的四种情形:首次、换档、系统版本变化导致判定变化、首次拿到句柄。 +/// +/// 注意:判定不变而上次**下发失败**时这里也返回 `false`——失败原因(`pref=none hresult=…`) +/// 已经在那一行日志里了,重复重试只会刷屏;真要重试应该由「换档 / 重启」触发,不是每帧。 +#[must_use] +pub fn needs_redispatch(previous: Option<&CornerApplied>, next: &CornerApplied) -> bool { + match previous { + None => true, + Some(previous) => previous != next, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/platform/win32/corner/tests.rs b/crates/app-iced/src/platform/win32/corner/tests.rs new file mode 100644 index 0000000..796db2d --- /dev/null +++ b/crates/app-iced/src/platform/win32/corner/tests.rs @@ -0,0 +1,289 @@ +//! `win32::corner` 的单测(M116 从 `corner.rs` 搬出来:那个文件要守 400 行上限, +//! 而日志行的形状断言本身就有几十行)。 +//! +//! 判据与真实读数的出处:`DevDocs/artifacts/m116/probe-table.md`。 + +use super::{format_line, 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 生效了看不见」的区分重新变成猜谜。 +/// 抓不住:**产品进程**里那一行(本测试只钉格式)。真值已有:2026-11-04 两次真跑产品 bin +/// (α=1.0 / α=0.85)的 `[corner] boot …` 两行,见 `DevDocs/artifacts/m116/README.md`。 +#[test] +fn the_log_line_shape_is_pinned_with_the_measured_readings() { + // 本机实测:Server 2025 / build 26100;读回 `E_INVALIDARG`;产品形状是 `overlapped`, + // 而 `layered=on` 只可能来自整窗 α(我们的 `WS_EX_LAYERED`) ⇒ 带 `suppressed_by=`。 + 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=overlapped layered=on", + Some("window_alpha"), + ); + assert_eq!( + line, + concat!( + "[corner] boot corner=policy=system source=dwm os=10.0.26100 ", + "excluded_reason=none radius=system pref=2 readback=? ", + "read_hr=0x80070057 frame=overlapped layered=on ", + "suppressed_by=window_alpha" + ) + ); + + // 设置里选「直角」:原因码必须从 `none` 变成 `user`,其余字段不动。 + let square = format_line( + "change", + &plan_corner(WindowCorner::Square, Some(os), Backend::Gpu), + WindowCorner::Square, + "pref=1 readback=? read_hr=0x80070057", + "frame=overlapped layered=off", + None, + ); + assert!( + square.contains("corner=policy=square source=square"), + "{square}" + ); + assert!(square.contains("excluded_reason=user"), "{square}"); + assert!(square.contains("frame=overlapped layered=off"), "{square}"); + // α = 1(不补分层)时**不许**出现 `suppressed_by=`:那一栏只在整窗 α 打开时才有意义。 + assert!(!square.contains("suppressed_by"), "{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}" + ); +} + +/// **同一策略 N 次只下发 / 只记 1 行**(M116 r1 复审 P2-4 的判据)。 +/// +/// 为什么要有这条:设置页的整窗 α / 面板 α / 暗化滑条每动一下都会触发一次外观变更, +/// 而圆角与它们无关。没有去重时拖一次滑条会刷出一串 `[corner] change …`, +/// 把「分辨有没有生效」的那唯一一行日志淹掉。 +/// +/// 抓得住:把去重删掉(`needs_redispatch` 恒 `true` 时这里会数到 N)、或指纹里漏掉某一维 +/// (换档 / 首次拿到句柄就不重下了)。 +/// 抓不住:真进程里到底打了几行(`eprintln!` 在 `App` 里,本测试只钉判定)。 +#[test] +fn the_same_policy_is_dispatched_only_once() { + use super::{needs_redispatch, plan_corner, CornerApplied}; + use crate::Backend; + use unlucky_presenter::prefs::{WindowCorner, WindowsVersion}; + + let os = WindowsVersion::new(10, 0, 26_100); + let plan = plan_corner(WindowCorner::System, Some(os), Backend::Gpu); + + // 模拟「滑条被拖动 20 次」:每次都是同一个策略、同一个系统、句柄一直在。 + let mut last: Option = None; + let mut dispatches = 0; + for _ in 0..20 { + let next = CornerApplied::new(WindowCorner::System, &plan, true, false); + if needs_redispatch(last.as_ref(), &next) { + dispatches += 1; + last = Some(next); + } + } + assert_eq!(dispatches, 1, "同一策略连按 20 次只该下发/记 1 次"); + + // 换档必须重下。 + let square = plan_corner(WindowCorner::Square, Some(os), Backend::Gpu); + assert!(needs_redispatch( + last.as_ref(), + &CornerApplied::new(WindowCorner::Square, &square, true, false) + )); + + // 句柄从无到有必须重下(首次拿到 HWND 时才真能设属性)。 + let mut no_window: Option = None; + let first = CornerApplied::new(WindowCorner::System, &plan, false, false); + assert!(needs_redispatch(no_window.as_ref(), &first)); + no_window = Some(first); + assert!(needs_redispatch( + no_window.as_ref(), + &CornerApplied::new(WindowCorner::System, &plan, true, false) + )); + + // 整窗 α 拉低(补分层)与拉回 1.0 都必须各重下/各记一行:系统行为在这两次确实不同。 + let opaque = CornerApplied::new(WindowCorner::System, &plan, true, false); + let translucent = CornerApplied::new(WindowCorner::System, &plan, true, true); + assert!( + needs_redispatch(Some(&opaque), &translucent), + "α 从 1.0 拉低必须重下(那一行才会出现 suppressed_by=window_alpha)" + ); + assert!( + needs_redispatch(Some(&translucent), &opaque), + "α 拉回 1.0 也必须重下(圆角请求重新生效)" + ); + assert!( + !needs_redispatch(Some(&translucent), &translucent), + "α 一直在 <1 时重复触发不该刷屏" + ); +} diff --git a/crates/app-iced/src/platform/win32/mod.rs b/crates/app-iced/src/platform/win32/mod.rs index 68e9b83..85239d3 100644 --- a/crates/app-iced/src/platform/win32/mod.rs +++ b/crates/app-iced/src/platform/win32/mod.rs @@ -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; diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 5b74c56..4458a5f 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -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) -> 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,233 @@ 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::(), + u32::try_from(std::mem::size_of::()).unwrap_or(4), + ) + } +} + +/// `HRESULT` 的成功判据:**看严重性位**(bit 31),不是「等于 0」。 +/// +/// Win32 里成功码不只有 `S_OK`(0):`S_FALSE`(1) 与各种 `S_*` / 信息级码都是成功,所以判据必须 +/// 是 `hr >= 0`。这条判据与 M116 探针的具体读数无关——它只是 `HRESULT` 的通用语义。 +/// +/// ⚠️ **别把 `0x7FF8FFA9` 当成 DWM 的返回值**(本分支一度这么写过,已订正):本机 +/// `DwmGetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 三种窗口样式实测**全部**返回 +/// `E_INVALIDARG`(`0x8007_0057`,负数);`0x7FF8FFA9` 是我早期用 `i32::unsigned_abs()` 打印 +/// HRESULT 时的产物(把负数的补码取了绝对值),它既不是 `S_OK` 也不是任何 DWM 信息码。 +#[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) { + let mut value: u32 = 0; + // SAFETY: out 指针指向本栈帧里的 `u32`,`size` 与之一致;失败时 DWM 不写。 + let hr = unsafe { + DwmGetWindowAttribute( + hwnd, + attribute, + (&raw mut value).cast::(), + u32::try_from(std::mem::size_of::()).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 { + 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 { + let mut rect = WinRect::default(); + // SAFETY: 同 `dwm_get_u32`,只是 out 类型换成 `RECT`。 + let hr = unsafe { + DwmGetWindowAttribute( + hwnd, + attribute, + (&raw mut rect).cast::(), + u32::try_from(std::mem::size_of::()).unwrap_or(16), + ) + }; + // 与 `dwm_get_u32_hr` 同一条判据:`HRESULT` 看严重性位,不是「等于 0」(judgment 只留一处)。 + hresult_ok(hr).then_some(rect) +} + +/// `GetWindowRect`:窗口矩形(屏幕坐标)。失败返回 `None`。 +#[cfg(target_os = "windows")] +pub(super) fn window_rect(hwnd: isize) -> Option { + 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 = "STATIC\0".encode_utf16().collect(); + let title: Vec = "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`)。探针用它取 `frame=` 那一栏(产品的真实样式见 +/// `probe::PRODUCT_STYLE`,**不是** `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 +390,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; diff --git a/crates/app-iced/src/platform/win32/transparency/alpha.rs b/crates/app-iced/src/platform/win32/transparency/alpha.rs new file mode 100644 index 0000000..0dcf596 --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency/alpha.rs @@ -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) -> 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)) +} diff --git a/crates/app-iced/src/platform/win32/transparency/dwm.rs b/crates/app-iced/src/platform/win32/transparency/dwm.rs new file mode 100644 index 0000000..3990bae --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency/dwm.rs @@ -0,0 +1,394 @@ +//! 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 起)。 +/// +/// ⚠️ **本机实测它不能当「圆不圆」的判据**(我最初以为能,被探针否掉了): +/// 三种窗口样式 × 三种状态(默认 / `DWMWCP_ROUND` / `DWMWCP_DONOTROUND`)读出来**全是 `1`**, +/// 没有任何区分度。保留它只作诊断读数(万一将来在别的机器上有变化),不参与判定。 +/// 证据:`DevDocs/artifacts/m116/probe-table.md` 的 `bdef` / `brnd` / `bsq` 三列。 +pub const DWMWA_VISIBLE_FRAME_BORDER_THICKNESS: u32 = 37; +/// `DWMWA_EXTENDED_FRAME_BOUNDS`:DWM 认为的窗口边界(**含**不可见的调整带)。 +pub const DWMWA_EXTENDED_FRAME_BOUNDS: u32 = 9; + +/// 一次圆角下发的结局(日志与测试共用)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CornerOutcome { + /// 非 Windows:空实现,什么都没做。 + UnsupportedPlatform, + /// 句柄为 0:还没拿到窗口,或窗口已销毁。 + NoWindow, + /// 已下发且回读一致。`readback == None` 表示**这一版 DWM 不接受回读** + /// (属性不被认识时 `DwmGetWindowAttribute` 返回 `E_INVALIDARG`),此时只有 `S_OK` 作证。 + Applied { + /// 下发下去的偏好值([`DWMWCP_ROUND`] / [`DWMWCP_DONOTROUND`])。 + preference: u32, + /// DWM 侧回读到的值(`None` = 读不回来)。 + readback: Option, + /// 回读那一次的 `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) { + #[cfg(target_os = "windows")] + { + super::dwm_get_u32_hr(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE) + } + #[cfg(not(target_os = "windows"))] + { + let _ = hwnd; + (None, 0) + } +} + +/// 读可见边框厚度(像素)。**诊断用,不可作判据**(本机三样式三态实测全是 1,见常量文档)。 +#[must_use] +pub fn visible_border_thickness(hwnd: isize) -> Option { + #[cfg(target_os = "windows")] + { + super::dwm_get_u32(hwnd, DWMWA_VISIBLE_FRAME_BORDER_THICKNESS) + } + #[cfg(not(target_os = "windows"))] + { + let _ = hwnd; + None + } +} + +/// 读 DWM 的窗口边界(`left, top, right, bottom`,屏幕坐标)。 +#[must_use] +pub fn extended_frame_bounds(hwnd: isize) -> Option<(i32, i32, i32, i32)> { + #[cfg(target_os = "windows")] + { + super::dwm_get_rect(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS) + .map(|rect| (rect.left, rect.top, rect.right, rect.bottom)) + } + #[cfg(not(target_os = "windows"))] + { + let _ = hwnd; + None + } +} + +/// 读 `GetWindowRect`(含 DWM 加出来的不可见边框),用于和 [`extended_frame_bounds`] 求差。 +#[must_use] +pub fn window_rect(hwnd: isize) -> Option<(i32, i32, i32, i32)> { + #[cfg(target_os = "windows")] + { + super::window_rect(hwnd).map(|rect| (rect.left, rect.top, rect.right, rect.bottom)) + } + #[cfg(not(target_os = "windows"))] + { + let _ = hwnd; + None + } +} + +/// 读窗口样式位(`GWL_STYLE` = `-16`):日志里 `frame=` 那一栏的取值来源。 +/// +/// ⚠️ 产品窗口**不是** `WS_POPUP`(M116 r4 复审订正):winit 在 `decorations = false` 下给的是 +/// `WS_CAPTION|WS_BORDER|...`(见 `probe::PRODUCT_STYLE`),真进程的 `frame=` 应为 `overlapped` +/// (关掉 min/max 则 `other`)。「无边框」是 `WM_NCCALCSIZE` 的效果,不是样式位。 +#[must_use] +pub fn window_style(hwnd: isize) -> Option { + #[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 { + #[cfg(target_os = "windows")] + { + (hwnd != 0).then(|| super::raw_ex_style(hwnd)) + } + #[cfg(not(target_os = "windows"))] + { + let _ = hwnd; + None + } +} + +/// `WS_POPUP` 位(`0x8000_0000`):**带 owner 的弹出窗**才置它;产品主窗口不用这一位。 +pub const WS_POPUP: isize = 0x8000_0000_u32 as isize; + +/// 帧样式的一句话标签(纯函数,日志用):`popup` / `overlapped` / `other`。 +/// +/// 为什么要它:用户的现象是「别的窗口有圆角,我们的没有」。把它打进日志,才能一眼看出 +/// 窗口形状这一项**不是**变量(产品恒为 `overlapped`)——真正让系统忽略圆角请求的是整窗 α +/// 引入的 `WS_EX_LAYERED`(见 `layered=` 与 `suppressed_by=window_alpha`),不是 `popup` 形状。 +#[must_use] +pub const fn frame_label(style: isize) -> &'static str { + const WS_OVERLAPPEDWINDOW_MASK: isize = 0x00CF_0000; + if style & WS_POPUP != 0 { + "popup" + } else if style & WS_OVERLAPPEDWINDOW_MASK == WS_OVERLAPPEDWINDOW_MASK { + "overlapped" + } else { + "other" + } +} + +/// 分层位的一句话标签(纯函数,日志用):`on` / `off`。 +#[must_use] +pub const fn layered_label(ex_style: isize) -> &'static str { + if ex_style & super::WS_EX_LAYERED != 0 { + "on" + } else { + "off" + } +} + +/// 一个窗口的样式摘要(`frame=overlapped layered=off`,产品真进程实测值);读不到时如实写 +/// `frame=? layered=?`。「`frame=popup`」只会出现在**带 owner 的弹出窗**上,产品不会。 +#[must_use] +pub fn style_summary(hwnd: isize) -> String { + let frame = window_style(hwnd).map_or("?", frame_label); + let layered = window_ex_style(hwnd).map_or("?", layered_label); + format!("frame={frame} layered={layered}") +} + +#[cfg(test)] +mod tests { + 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=?"); +} diff --git a/crates/app-iced/src/platform/win32/transparency/probe.rs b/crates/app-iced/src/platform/win32/transparency/probe.rs new file mode 100644 index 0000000..2a307ac --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency/probe.rs @@ -0,0 +1,342 @@ +//! M116 的**受控对照实验**:同进程里造几种窗口样式,逐个读 DWM 属性。 +//! +//! ## 为什么需要它 +//! +//! 用户的观察是「同一台机器上别的窗口都有圆角,只有我们的没有」。于是「没圆角」至少有三种 +//! 可能:①这是 DWM 的默认行为(我们没申请);②某个**样式位**把它 opt-out 了;③属性根本设不上。 +//! +//! 只在自己的窗口上读一次,分不清 ①②③。所以这里**同一进程**里造几个窗口,除「样式」外 +//! 条件全相同: +//! +//! | 标签 | 样式 | 它代表什么 | +//! |---|---|---| +//! | `decorated` | `WS_OVERLAPPEDWINDOW` | 用户说的「别的窗口」(对照) | +//! | `probe:popup` | 裸 `WS_POPUP` | **对照,不是产品形状**:带 owner 的弹出窗才用这一位 | +//! | `probe:popup+layered` | `WS_POPUP` + `WS_EX_LAYERED`(α) | **对照**:把两个嫌疑同时打开 | +//! | `product` | [`PRODUCT_STYLE`](α = 1,不补分层) | 产品的**真实**样式(出厂态) | +//! | `product+alpha` | [`PRODUCT_STYLE`] + `WS_EX_LAYERED`(α) | 产品真实样式 + 用户把整窗 α 拉低 | +//! +//! ## ⚠️ 产品窗口**不是** `WS_POPUP`(M116 r4 复审订正) +//! +//! iced/winit 在 `decorations = false` 下**不会**用 `WS_POPUP`:winit 的 +//! `window_state.rs::to_window_styles` 起点是 `WS_CAPTION|WS_BORDER|WS_CLIPSIBLINGS|WS_SYSMENU`, +//! 可缩放/可最大化时再加 `WS_THICKFRAME|WS_MINIMIZEBOX|WS_MAXIMIZEBOX`;`WS_POPUP` 只在 +//! `WindowFlags::POPUP`(**带 owner** 的弹出窗)时才加。产品的「无边框观感」来自 +//! `WM_NCCALCSIZE` 把客户区铺满,**不是**样式位。 +//! +//! 所以本文件早期那句「`popup+layered` = 本产品窗口的完整形状」是**错的**:那一行的读数 +//! (尤其 `Δframe = +0,+0`)只属于假形状,不能拿去解释产品窗口。要解释产品,看 `product` +//! 与 `product+alpha` 两行。 +//! +//! ## 这个实验能证明什么、不能证明什么 +//! +//! **能**:属性层能不能开(`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`(`0x8000_0000`):**带 owner 的弹出窗**才用它;产品主窗口没有 owner,不用。 +pub const WS_POPUP: u32 = 0x8000_0000; +/// `WS_OVERLAPPEDWINDOW`:普通有边框窗口(对照组的「别的窗口」)。 +pub const WS_OVERLAPPEDWINDOW: u32 = 0x00CF_0000; + +/// `WS_CAPTION`(`WS_BORDER | WS_DLGFRAME`)。 +const WS_CAPTION: u32 = 0x00C0_0000; +/// `WS_BORDER`(= `WS_THICKFRAME` 的低位别名,`0x0080_0000`)。 +const WS_BORDER: u32 = 0x0080_0000; +/// `WS_CLIPSIBLINGS`:子窗口层叠顺序位(DWM 不看它,winit 的起点里有)。 +const WS_CLIPSIBLINGS: u32 = 0x0400_0000; +/// `WS_SYSMENU`。 +const WS_SYSMENU: u32 = 0x0008_0000; +/// `WS_THICKFRAME`(可缩放边框)。 +const WS_THICKFRAME: u32 = 0x0004_0000; +/// `WS_MINIMIZEBOX`。 +const WS_MINIMIZEBOX: u32 = 0x0002_0000; +/// `WS_MAXIMIZEBOX`。 +const WS_MAXIMIZEBOX: u32 = 0x0001_0000; + +/// **产品窗口的真实样式位**(iced/winit,`decorations = false`,可缩放、可最大化、可最小化)。 +/// +/// 出处(可复核):winit `window_state.rs::to_window_styles` 从 +/// `WS_CAPTION|WS_BORDER|WS_CLIPSIBLINGS|WS_SYSMENU` 起手,`RESIZABLE` / `MAXIMIZABLE` / +/// `MINIMIZABLE` 分别加 `WS_THICKFRAME` / `WS_MAXIMIZEBOX` / `WS_MINIMIZEBOX`;`WS_POPUP` +/// 只在 `WindowFlags::POPUP`(带 owner)时才加——产品主窗口不带。 +/// +/// 这些**位**是输入(照抄 winit 的产物,带出处);表里那一行 `product` 的**读数**是跑出来的, +/// 不是我写死的。 +pub const PRODUCT_STYLE: u32 = WS_CAPTION + | WS_BORDER + | WS_CLIPSIBLINGS + | WS_SYSMENU + | WS_THICKFRAME + | WS_MINIMIZEBOX + | WS_MAXIMIZEBOX; + +/// `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, + /// 什么都没设时 DWM 侧的偏好值(`0` = `DWMWCP_DEFAULT`)。 + pub preference_default: Option, + /// 读上面那一格时的 `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, + /// 圆角状态下的可见边框厚度(像素)。 + pub border_round: Option, + /// 直角状态下的可见边框厚度(像素)。 + pub border_square: Option, + /// `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 { + let show = |value: Option| 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 返回空表)。 +/// +/// 前两行是对照(**不是产品形状**),后两行才是产品:`product` = 出厂态(α = 1,不补分层), +/// `product+alpha` = 用户把整窗 α 拉低之后的真实形状。 +/// +/// 每个窗口用完立刻销毁:这是诊断路径,不许留 HWND。 +#[must_use] +pub fn controlled_experiment() -> Vec { + let mut rows = Vec::new(); + for (label, style, ex_style) in [ + ("decorated", WS_OVERLAPPEDWINDOW, 0u32), + ("probe:popup", WS_POPUP, 0u32), + ("probe:popup+layered", WS_POPUP, WS_EX_LAYERED_U32), + ("product", PRODUCT_STYLE, 0u32), + ("product+alpha", PRODUCT_STYLE, 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::super::dwm::frame_label; + use super::{ + controlled_experiment, ProbeRow, PRODUCT_STYLE, 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); + } + + /// **产品窗口不是 popup**(M116 r4 复审的核心事实,钉住它,别再写回去)。 + /// + /// 抓得住:有人把 `PRODUCT_STYLE` 改回 `WS_POPUP`(那会让「产品形状」整条证据链回到错的假设上); + /// 也抓得住 `PRODUCT_STYLE` 丢掉 `WS_CAPTION`/`WS_THICKFRAME`(那会让 `Δframe` 从 `+0,−7` + /// 掉成 `+0,+0`,把「产品没有边框带」这个错结论复活)。 + /// 抓不住:真进程上 winit 到底置了哪些位(那看 `[corner] … frame=` 的运行时读数)。 + #[test] + fn the_product_window_is_overlapped_not_popup() { + assert_eq!( + PRODUCT_STYLE & WS_POPUP, + 0, + "产品主窗口没有 owner ⇒ 不带 WS_POPUP" + ); + assert_ne!( + PRODUCT_STYLE & 0x00C0_0000, + 0, + "WS_CAPTION 在(winit 起点)" + ); + assert_ne!(PRODUCT_STYLE & 0x0004_0000, 0, "WS_THICKFRAME 在(可缩放)"); + assert_eq!(frame_label(PRODUCT_STYLE as isize), "overlapped"); + assert_eq!(frame_label(WS_POPUP as isize), "popup"); + } + + /// 实验固定五行,顺序固定:两行对照 + 两行产品 + `decorated` 参照。 + /// + /// 抓得住:有人把分层那一行或产品行删了/调换——那样「layered 会不会让 DWM 忽略圆角请求」 + /// 与「产品真实形状有没有边框带」这两条对照就都没了。 + /// 抓不住:读数本身(那要么真机跑,要么看报告里的表)。 + #[test] + fn the_experiment_always_covers_the_same_five_rows_in_order() { + let rows = controlled_experiment(); + assert_eq!(rows.len(), 5); + let labels: Vec<_> = rows.iter().map(|row| row.label).collect(); + assert_eq!( + labels, + [ + "decorated", + "probe:popup", + "probe:popup+layered", + "product", + "product+alpha" + ] + ); + assert_eq!(rows[0].style, WS_OVERLAPPEDWINDOW); + assert_eq!(rows[1].style, WS_POPUP); + assert_eq!(rows[2].ex_style & 0x0008_0000, 0x0008_0000); + // 产品两行:样式位相同(都是真实样式),只有 layered 一位不同。 + assert_eq!(rows[3].style, PRODUCT_STYLE); + assert_eq!(rows[4].style, PRODUCT_STYLE); + assert_eq!(rows[3].ex_style & 0x0008_0000, 0, "出厂态 α=1 ⇒ 不补分层"); + assert_eq!(rows[4].ex_style & 0x0008_0000, 0x0008_0000, "α<1 ⇒ 补分层"); + } + + /// 表头与每行的竖线数一致(`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")); + } +} diff --git a/crates/app-iced/src/platform/win32/transparency/tests.rs b/crates/app-iced/src/platform/win32/transparency/tests.rs new file mode 100644 index 0000000..4f55898 --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency/tests.rs @@ -0,0 +1,233 @@ +//! `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()); + } +} + +/// **成功判据的语义**:`HRESULT` 看严重性位(bit 31),不是「等于 0」——`S_FALSE`(1) 也算成功。 +/// +/// ⚠️ 这条测试**不是**「本机 DWM 会返回某个正数」的证据:本机 +/// `DwmGetWindowAttribute` 三种窗口样式实测全 `E_INVALIDARG (0x80070057)`(失败)。 +/// 下面那个 `0x7FF8_FFA9` 是**合成值**,只用来钉住「非零但严重性位为 0 ⇒ 成功」这条规则, +/// 以及钉住「`unsigned_abs()` 不能用来打印 HRESULT」这个打印坑。 +/// +/// 抓得住:把成功判据写回 `hr == 0`;把打印改回 `unsigned_abs()`。 +/// 抓不住: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 也是成功"); + assert!( + hresult_ok(0x7FF8_FFA9), + "合成值:非零但严重性位为 0 ⇒ 按 HRESULT 语义是成功(不是 DWM 的实测返回值)" + ); + // 失败码:严重性位为 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); + // 直接遍历 `controlled_experiment` 的**同一份**行定义(含 `product` / `product+alpha`), + // 免得这里另抄一份样式表、跟探针悄悄分叉。 + for row in super::controlled_experiment() { + let hwnd = super::create_probe_window(row.style, row.ex_style); + if hwnd == 0 { + println!("[corner] {} window=none", row.label); + continue; + } + super::show_probe_window(hwnd); + // layered=on 的那几行(产品的 α<1 档与裸 popup 对照)按裁定 A 带 `suppressed_by=`。 + let layered = row.ex_style & 0x0008_0000 != 0; + println!( + "{}", + crate::platform::win32::corner::format_line( + row.label, + &plan, + WindowCorner::System, + &super::apply_corner(hwnd, &plan).describe(), + &super::dwm::style_summary(hwnd), + layered.then_some("window_alpha"), + ) + ); + 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) + ); +} diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index 67f67e3..e726fc0 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -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>, diff --git a/crates/core/src/setting/appearance/mod.rs b/crates/core/src/setting/appearance/mod.rs index 03c8f15..e104f57 100644 --- a/crates/core/src/setting/appearance/mod.rs +++ b/crates/core/src/setting/appearance/mod.rs @@ -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(()) } } diff --git a/crates/core/src/setting/appearance/modes.rs b/crates/core/src/setting/appearance/modes.rs index 43d9d15..4828d3b 100644 --- a/crates/core/src/setting/appearance/modes.rs +++ b/crates/core/src/setting/appearance/modes.rs @@ -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 { let mut found = Vec::new(); @@ -148,5 +190,18 @@ pub fn diagnostics(doc: &SettingDocument) -> Vec { }); } } + 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 } diff --git a/crates/core/src/setting/appearance/tests.rs b/crates/core/src/setting/appearance/tests.rs index f1930ba..4775522 100644 --- a/crates/core/src/setting/appearance/tests.rs +++ b/crates/core/src/setting/appearance/tests.rs @@ -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); +} diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index ea89749..5e31902 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -52,9 +52,19 @@ 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` ⇒ 跟随系统,与写进文件后行为一致。 +/// +/// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时, +/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` → +/// `ReadOnlyPreserveSchema` → [`super::schema::SchemaCompat::access`] → `FileAccess::ReadOnly`。 +/// 也就是说**整份配置对它只读**(值照读、`store.set` 一律 `SettingError::ReadOnly`、 +/// `save` 跳过),不是「只是少一档」。这是 minor 提升的既定代价——也正是这条键必须推 minor +/// 而不是偷偷塞进 1.4.0 的原因:回退到旧版本会出现「设置改不动」,而原因只在 +/// `$schema` 那一行里,不看版本号无从排查。 #[must_use] pub fn launcher_version() -> SchemaVersion { - SchemaVersion::new(1, 4, 0) + SchemaVersion::new(1, 5, 0) } /// `config/launcher.json` 的 schema。 @@ -336,6 +346,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( diff --git a/crates/core/tests/setting/appearance_keys.rs b/crates/core/tests/setting/appearance_keys.rs index 80db21e..c235bd9 100644 --- a/crates/core/tests/setting/appearance_keys.rs +++ b/crates/core/tests/setting/appearance_keys.rs @@ -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, diff --git a/crates/core/tests/setting/isolation_keys.rs b/crates/core/tests/setting/isolation_keys.rs index 46636e1..eab24cd 100644 --- a/crates/core/tests/setting/isolation_keys.rs +++ b/crates/core/tests/setting/isolation_keys.rs @@ -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 走)不需要新步骤,老文件缺键即默认值。 diff --git a/crates/core/tests/setting/java_install_keys.rs b/crates/core/tests/setting/java_install_keys.rs index a732379..90f2881 100644 --- a/crates/core/tests/setting/java_install_keys.rs +++ b/crates/core/tests/setting/java_install_keys.rs @@ -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, diff --git a/crates/design/src/shape.rs b/crates/design/src/shape.rs index 9126982..fe2e4a1 100644 --- a/crates/design/src/shape.rs +++ b/crates/design/src/shape.rs @@ -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; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 7da6a33..cc631e9 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -57,6 +57,11 @@ 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", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"), + ("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"), ("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"), ("DownloadTitle", "Version management", "版本管理"), ("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"), diff --git a/crates/i18n/src/catalog/settings/appearance.rs b/crates/i18n/src/catalog/settings/appearance.rs index d32449e..907e9a2 100644 --- a/crates/i18n/src/catalog/settings/appearance.rs +++ b/crates/i18n/src/catalog/settings/appearance.rs @@ -1,11 +1,34 @@ -//! 设置页「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 => ( + "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", + "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。" + ), + SettingsAppearanceCornerSuspended => ( + "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", + "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。" + ), + } } }; } diff --git a/crates/presenter/src/prefs.rs b/crates/presenter/src/prefs.rs index d2224ad..a0dc13a 100644 --- a/crates/presenter/src/prefs.rs +++ b/crates/presenter/src/prefs.rs @@ -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; @@ -174,10 +185,10 @@ impl AppearancePrefs { } } -/// 外观下层的**只读**快照:整窗 α / 标题栏三态 / 配色来源(D36·D37·D38)。 +/// 外观下层的**只读**快照:整窗 α / 标题栏三态 / 配色来源 / 窗口圆角(D36·D37·D38 · M116)。 /// /// 不并进 [`AppearancePrefs`] 的两个理由:那个结构体由 `settings_bridge` 逐字段字面量 -/// 构造(加字段就得改那边),且这三个键只读不写——落盘仍走 core 的 `Appearance::write`。 +/// 构造(加字段就得改那边),且这**四个**键只读不写——落盘仍走 core 的 `Appearance::write`。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct AppearanceBase { /// 整窗透明度 [`WINDOW_ALPHA_MIN`]~1.0(1.0 = 完全不透明) @@ -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, ]; } diff --git a/crates/presenter/src/prefs/tests.rs b/crates/presenter/src/prefs/tests.rs index 1a8b2f2..2cb058d 100644 --- a/crates/presenter/src/prefs/tests.rs +++ b/crates/presenter/src/prefs/tests.rs @@ -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); +} diff --git a/crates/presenter/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs index 50df91c..3b9021c 100644 --- a/crates/presenter/src/settings_bridge.rs +++ b/crates/presenter/src/settings_bridge.rs @@ -101,12 +101,13 @@ impl SettingsBridge { ) } - /// 外观**下层**的只读快照(D36·D37·D38):整窗 α / 标题栏三态 / 配色来源档。 + /// 外观**下层**的只读快照(D36·D37·D38 · M116):整窗 α / 标题栏三态 / 配色来源档 / + /// 窗口圆角策略(`AppearanceBase` 的**四个**字段)。 /// /// 为什么不并进 [`SettingsBridge::load`] 的返回值:那个结构体(`AppearancePrefs`)是 - /// **UI 写入用**的(每个字段都会被 `persist` 回写),而这三个键**只读不写**—— + /// **UI 写入用**的(每个字段都会被 `persist` 回写),而这**四个**键**只读不写**—— /// 落盘走 core 的 `Appearance::write`。并进去会让每个 `AppearancePrefs` 构造点都要补字段, - /// 还会让「这三项只读」这件事在类型上消失。 + /// 还会让「这四项只读」这件事在类型上消失。 /// /// UI 侧(`crates/app-iced`)拿它读启动值:`app-iced` 不直接依赖 `unlucky-core`, /// 所以「把 core 的 `SettingDocument` 翻译成只读快照」这一步必须留在本 crate。 @@ -311,13 +312,13 @@ mod tests { assert!(!appearance.bg_active(), "路径空也不算生效"); } - /// `load_base()` 是「重启后读回整窗 α / 配色来源」的唯一通道(M93)。 + /// `load_base()` 是「重启后读回整窗 α / 配色来源 / 窗口圆角」的唯一通道(M93 · M116)。 /// /// 抓得住:①文件里写着 `window_opacity=0.6` / `palette_source=wallpaper` 时能被**新开的** /// bridge 读到(= 重启语义);②缺键时回出厂(1.0 / preset),而不是 `0.0` 或随机档。 /// 抓不住:UI 侧是否真的用它来下发窗口 α(那由 `app-iced` 的日志与单测证明)。 #[test] - fn load_base_reads_the_three_read_only_keys_and_defaults_when_missing() { + fn load_base_reads_the_four_read_only_keys_and_defaults_when_missing() { let path = temp_config("base"); // 文件由「外部写者」(CLI / AI / 设置页的 SetSetting)产生,这里直接落 JSON。 std::fs::write(