From abc51a957bd24532c9cb1ec828e0e50800ec437e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 19:14:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M116=20r4/r5=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=BA=A7=E5=93=81=E7=AA=97=E5=8F=A3=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=20WS=5FPOPUP=EF=BC=88=E7=9C=9F=E5=87=B6=E6=98=AF?= =?UTF-8?q?=E6=95=B4=E7=AA=97=20=CE=B1=20=E7=9A=84=20layered=EF=BC=89+=20?= =?UTF-8?q?=E8=A3=81=E5=AE=9A=20A=20=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r4 复审 p1(证据链前提错误)+ 裁定 A(D47:整窗透明度优先于 DWM 圆角)。 ## 事实订正(四处错前提 + 两处残留断言) - `probe.rs`:删掉「winit 在 decorations=false 下给 WS_POPUP」与「popup+layered = 本产品 窗口的完整形状」。产品样式按 winit `window_state.rs::to_window_styles` 的来源写成 `PRODUCT_STYLE`(`WS_CAPTION|WS_BORDER|WS_CLIPSIBLINGS|WS_SYSMENU|WS_THICKFRAME| WS_MINIMIZEBOX|WS_MAXIMIZEBOX` = `0x04CF0000`,**不含 WS_POPUP**),并新增 `product` / `product+alpha` 两行(五行实验)。裸 popup 行改名为 `probe:popup*`, 明确标注「对照,不是产品形状」。 - `corner.rs`:平台矩阵第 ③ 条改成「调整带只给带 WS_CAPTION|WS_THICKFRAME 的窗口; 裸 popup 假形状上的 `+0,+0` 不能解释产品」;补第 ④ 条「整窗 α 会关掉圆角(裁定 A)」 并说明日志里 `pref=` 有值即证明请求确实发出。 - `app/update/appearance.rs` 与 `transparency/{dwm.rs,transparency.rs}` 的注释:把「第一嫌疑是 无边框 WS_POPUP」改成「整窗 α 引入的 WS_EX_LAYERED」;`frame=` 的真进程取值写明是 `overlapped`(关 min/max 则 `other`),**永远不会是 popup**。 - 实测复核(`m116_corner_controlled_experiment`,本机 Server 2025 / RDP 断开): `product` 行 `Δframe = +0,-7`(与 `decorated` 同格),`product+alpha` 也是 `+0,-7` ⇒ 分层位不改变那条带子,`Δframe` 不能解释圆角失效。 ## 裁定 A 落地 - 日志新增中立观测栏 `suppressed_by=window_alpha`(`format_line` 多一个参数;进 `CornerApplied` 指纹,α 1.0↔<1 各触发一次重下/一行日志)。只陈述「分层位来自我们的整窗 α」。 - 设置页:外观说明句写明「开启整窗透明度时 Windows 不会给窗口圆角,回到 100% 自动恢复」; 新增 `SettingsAppearanceCornerSuspended` 一档,仅在「跟随系统 + α<1」时作为**当前状态提示** 出现;档位本身照常显示用户选的值(不偷偷改成「直角」)。中英双份 + golden。 - 未动:per-pixel α、D36 的 LWA_ALPHA 实现、α 与圆角的优先级。 ## 顺带 - `presenter/prefs.rs` 的 `AppearanceBase` 文档「三个键/只读」→「四个键」(r4 P3)。 - 证据目录(DevDocs/artifacts/m116/{README.md,probe-table.md})按上面重写:五行真值表原样贴出、 产品行标明「跑出来的不是手写的」、旧的「无边框 ⇒ 没有边框带」结论改为「只在假形状上成立, 产品不用这个形状」。 门禁:check 0 / fmt --check 0 / `cargo test -p unlucky-app-iced --lib` 0(448 passed, 0 failed, 2 ignored)。ignore 探针实跑:`PROBE_RC=0`,两行产品读数见报告。 --- crates/app-iced/src/app/update/appearance.rs | 20 ++- .../app-iced/src/pages/appearance/section.rs | 17 +-- crates/app-iced/src/platform/win32/corner.rs | 51 +++++-- .../src/platform/win32/corner/tests.rs | 40 ++++-- .../src/platform/win32/transparency.rs | 3 +- .../src/platform/win32/transparency/dwm.rs | 14 +- .../src/platform/win32/transparency/probe.rs | 130 +++++++++++++++--- .../src/platform/win32/transparency/tests.rs | 27 ++-- crates/i18n/src/catalog/golden.rs | 3 +- .../i18n/src/catalog/settings/appearance.rs | 8 +- crates/presenter/src/prefs.rs | 4 +- 11 files changed, 246 insertions(+), 71 deletions(-) diff --git a/crates/app-iced/src/app/update/appearance.rs b/crates/app-iced/src/app/update/appearance.rs index f06a805..e95f6f1 100644 --- a/crates/app-iced/src/app/update/appearance.rs +++ b/crates/app-iced/src/app/update/appearance.rs @@ -17,7 +17,13 @@ //! //! 用户报的现象是「别的窗口都有圆角,只有我们的没有」。没有这一行,事后无法区分 //! 「没生效」「生效了但看不见」「这台系统根本没有这个能力」;`frame` / `layered` 两栏则是 -//! 「我们被 DWM 排除」的第一嫌疑(`WS_POPUP` 无边框、`WS_EX_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 固定档)。 @@ -47,15 +53,19 @@ impl App { platform::current_windows_version(), self.appearance.backend, ); - let next = CornerApplied::new(policy, &plan, self.hwnd.is_some()); + // 整窗 α < 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`),因为那里能在没有窗口的环境里被单测钉住。 - // 「我们被 DWM 排除」的第一嫌疑是无边框(`WS_POPUP`)与整窗 α(`WS_EX_LAYERED`), - // 所以 `frame=` / `layered=` 必须和圆角结论在同一行——否则以后只能猜是哪一条。 + // 关键的三栏必须在同一行:`pref=`(请求发出去了)、`layered=`(分层位在不在,来自 + // 整窗 α)、`frame=`(窗口形状 = `overlapped`,不是 `popup`)——否则以后只能猜是哪一条。 let (outcome, style) = match self.hwnd { Some(hwnd) => (apply_corner(hwnd, &plan).describe(), style_summary(hwnd)), None => ( @@ -65,7 +75,7 @@ impl App { }; eprintln!( "{}", - corner::format_line(context, &plan, policy, &outcome, &style) + corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by) ); } } diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs index f8bb34f..a35dfd5 100644 --- a/crates/app-iced/src/pages/appearance/section.rs +++ b/crates/app-iced/src/pages/appearance/section.rs @@ -68,9 +68,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ), )); - // ② 窗口圆角(M116):**只有两档**,且各平台能力不同 ⇒ 一行控件 + 一句人话说明。 - // 不做 8/12/16 档:系统不暴露圆角半径,自绘圆角在无边框 + 整窗 α 上要裁内容层 - // (会把标题栏三键与缩放热区一起裁掉)。文案不许暗示所有平台一致。 + // ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。 let corner = state.window_corner(); rows = rows.push(choice_row( ui, @@ -90,11 +88,14 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ), ], )); - rows = rows.push(note_line( - ui, - t(Key::SettingsAppearanceCornerHint), - ui.color(Role::OnSurfaceVariant), - )); + // 档位照常显示用户选的值(不偷偷改成「直角」);α<1 时只补一句状态(裁定 A)。 + let mut notes = vec![t(Key::SettingsAppearanceCornerHint)]; + if corner == WindowCorner::System && state.window_opacity < 1.0 { + notes.push(t(Key::SettingsAppearanceCornerSuspended)); + } + for note in notes { + rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); + } // ③ 面板透明度(CPU 档强制 1.0) let forced = state.panel_is_forced(); diff --git a/crates/app-iced/src/platform/win32/corner.rs b/crates/app-iced/src/platform/win32/corner.rs index 2c641a7..b4a8c87 100644 --- a/crates/app-iced/src/platform/win32/corner.rs +++ b/crates/app-iced/src/platform/win32/corner.rs @@ -19,10 +19,21 @@ //! | Linux X11 / Wayland | 不承诺,直角即如实结果 | **推断**(未真机验) | //! //! ⚠️ **「Win11 上我们的窗口一定有圆角、边缘平滑」这句话本分支的证据不支持**,别写回去。 -//! 本机(Server 2025 build 26100、RDP 会话断开)只测到三件事: -//! ① `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 被接受; +//! 本机(Server 2025 build 26100、RDP 会话断开)测到的是: +//! ① `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 被接受(三种窗口样式都接受); //! ② `DwmGetWindowAttribute` 同一属性返回 `E_INVALIDARG`(本会话不支持回读,与窗口形状无关); -//! ③ 无边框窗口没有 DWM 调整带(`EXTENDED_FRAME_BOUNDS − GetWindowRect` = `+0,+0`,有边框是 `+0,−7`)。 +//! ③ 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` ⇒ 直角。 @@ -183,7 +194,9 @@ pub fn plan_corner( /// 产品日志行的**唯一格式化落点**(纯函数:把四段文本拼成一个固定形状)。 /// /// ```text -/// [corner] corner=policy=system source=dwm os=10.0.26100 excluded_reason=none /// radius=system pref=2 readback=? read_hr=0x80070057 frame=popup layered=on +/// [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 才能跑,而**这一行的形状** @@ -192,6 +205,10 @@ pub fn plan_corner( /// /// 参数里 `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, @@ -199,11 +216,18 @@ pub fn format_line( policy: WindowCorner, outcome: &str, style: &str, + suppressed_by: Option<&'static str>, ) -> String { - format!( + 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 } /// 一次「已经下发过」的**指纹**:只由判定本身与「当时有没有窗口」组成,不含读数。 @@ -224,17 +248,28 @@ pub struct CornerApplied { 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) -> Self { + 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, } } } diff --git a/crates/app-iced/src/platform/win32/corner/tests.rs b/crates/app-iced/src/platform/win32/corner/tests.rs index e60aadd..5d70945 100644 --- a/crates/app-iced/src/platform/win32/corner/tests.rs +++ b/crates/app-iced/src/platform/win32/corner/tests.rs @@ -162,7 +162,8 @@ fn the_log_line_carries_every_field() { /// 抓不住:产品进程里那一行到底长什么样(本 mission 没构建 bin;合并态由 tower 编 `--bins` 补)。 #[test] fn the_log_line_shape_is_pinned_with_the_measured_readings() { - // 本机实测:Server 2025 / build 26100;读回 `E_INVALIDARG`;popup 窗口 layered=on。 + // 本机实测: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( @@ -170,14 +171,16 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() { &plan, WindowCorner::System, "pref=2 readback=? read_hr=0x80070057", - "frame=popup layered=on", + "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=popup layered=on" + "read_hr=0x80070057 frame=overlapped layered=on ", + "suppressed_by=window_alpha" ) ); @@ -187,14 +190,17 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() { &plan_corner(WindowCorner::Square, Some(os), Backend::Gpu), WindowCorner::Square, "pref=1 readback=? read_hr=0x80070057", - "frame=popup layered=off", + "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=popup layered=off"), "{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); @@ -239,7 +245,7 @@ fn the_same_policy_is_dispatched_only_once() { let mut last: Option = None; let mut dispatches = 0; for _ in 0..20 { - let next = CornerApplied::new(WindowCorner::System, &plan, true); + let next = CornerApplied::new(WindowCorner::System, &plan, true, false); if needs_redispatch(last.as_ref(), &next) { dispatches += 1; last = Some(next); @@ -251,16 +257,32 @@ fn the_same_policy_is_dispatched_only_once() { let square = plan_corner(WindowCorner::Square, Some(os), Backend::Gpu); assert!(needs_redispatch( last.as_ref(), - &CornerApplied::new(WindowCorner::Square, &square, true) + &CornerApplied::new(WindowCorner::Square, &square, true, false) )); // 句柄从无到有必须重下(首次拿到 HWND 时才真能设属性)。 let mut no_window: Option = None; - let first = CornerApplied::new(WindowCorner::System, &plan, false); + 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) + &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/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 90c6809..4458a5f 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -353,7 +353,8 @@ pub(super) fn destroy_probe_window(hwnd: isize) { } } -/// 读窗口样式位(`GWL_STYLE` = `-16`)。探针用它证明「无边框 = `WS_POPUP`」这条前提。 +/// 读窗口样式位(`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; diff --git a/crates/app-iced/src/platform/win32/transparency/dwm.rs b/crates/app-iced/src/platform/win32/transparency/dwm.rs index 330d2c8..7b85d3d 100644 --- a/crates/app-iced/src/platform/win32/transparency/dwm.rs +++ b/crates/app-iced/src/platform/win32/transparency/dwm.rs @@ -206,7 +206,11 @@ pub fn window_rect(hwnd: isize) -> Option<(i32, i32, i32, i32)> { } } -/// 读窗口样式位(`GWL_STYLE` = `-16`):证明「无边框 = `WS_POPUP`」这条前提。 +/// 读窗口样式位(`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")] @@ -234,14 +238,14 @@ pub fn window_ex_style(hwnd: isize) -> Option { } } -/// `WS_POPUP` 位:无边框窗口(winit 在 `decorations = false` 下给的形状)。 +/// `WS_POPUP` 位(`0x8000_0000`):**带 owner 的弹出窗**才置它;产品主窗口不用这一位。 pub const WS_POPUP: isize = 0x8000_0000_u32 as isize; /// 帧样式的一句话标签(纯函数,日志用):`popup` / `overlapped` / `other`。 /// -/// 为什么要它:用户的现象是「别的窗口有圆角,我们的没有」,而**我们被排除的第一嫌疑**就是 -/// `WS_POPUP`(无边框)。把它打进日志,事后才能把「这台机器的 DWM 对 popup 窗口不套圆角」 -/// 与「属性没设上」分开。 +/// 为什么要它:用户的现象是「别的窗口有圆角,我们的没有」。把它打进日志,才能一眼看出 +/// 窗口形状这一项**不是**变量(产品恒为 `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; diff --git a/crates/app-iced/src/platform/win32/transparency/probe.rs b/crates/app-iced/src/platform/win32/transparency/probe.rs index da97601..2a307ac 100644 --- a/crates/app-iced/src/platform/win32/transparency/probe.rs +++ b/crates/app-iced/src/platform/win32/transparency/probe.rs @@ -1,24 +1,37 @@ -//! M116 的**受控对照实验**:同进程里造三种窗口样式,逐个读 DWM 属性。 +//! M116 的**受控对照实验**:同进程里造几种窗口样式,逐个读 DWM 属性。 //! //! ## 为什么需要它 //! //! 用户的观察是「同一台机器上别的窗口都有圆角,只有我们的没有」。于是「没圆角」至少有三种 -//! 可能:①这是 DWM 的默认行为(我们没申请);②我们用什么样式把它 opt-out 了 -//! (`decorations=false` ⇒ `WS_POPUP`,以及 D36 的整窗 `WS_EX_LAYERED`);③属性根本设不上。 +//! 可能:①这是 DWM 的默认行为(我们没申请);②某个**样式位**把它 opt-out 了;③属性根本设不上。 //! -//! 只在自己的窗口上读一次,分不清 ①②③。所以这里**同一进程**里造三个窗口,除「样式」外 +//! 只在自己的窗口上读一次,分不清 ①②③。所以这里**同一进程**里造几个窗口,除「样式」外 //! 条件全相同: //! -//! | 标签 | 样式 | 对应现实 | +//! | 标签 | 样式 | 它代表什么 | //! |---|---|---| -//! | `decorated` | `WS_OVERLAPPEDWINDOW` | 用户说的「别的窗口」——Win11 上就是圆的 | -//! | `popup` | `WS_POPUP` | winit 在 `decorations = false` 下给的样式 | -//! | `popup+layered` | `WS_POPUP` + `WS_EX_LAYERED` + `LWA_ALPHA 205` | 本产品窗口的完整形状(D36) | +//! | `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_VISIBLE_FRAME_BORDER_THICKNESS` 与 //! `DWMWA_EXTENDED_FRAME_BOUNDS - GetWindowRect` 的数值差是否随样式变化。 //! //! **不能**:角上到底是不是透明的(那要合成输出像素,本机 RDP 断连拿不到)。所以本文件 @@ -31,10 +44,43 @@ use super::dwm::{ window_rect, window_style, CornerOutcome, DWMWCP_DONOTROUND, DWMWCP_ROUND, }; -/// `WS_POPUP`:无边框弹出窗口(winit 在 `decorations = false` 下用它)。 +/// `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; @@ -112,14 +158,19 @@ pub const TABLE_HEADER: &str = "| style | requested | exstyle | readback | pref /// 跑一遍受控对照实验(非 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), - ("popup", WS_POPUP, 0u32), - ("popup+layered", WS_POPUP, WS_EX_LAYERED_U32), + ("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)); } @@ -204,7 +255,10 @@ fn empty_row(label: &'static str, style: u32, ex_style: u32) -> ProbeRow { #[cfg(test)] mod tests { - use super::{controlled_experiment, ProbeRow, TABLE_HEADER, WS_OVERLAPPEDWINDOW, WS_POPUP}; + use super::super::dwm::frame_label; + use super::{ + controlled_experiment, ProbeRow, PRODUCT_STYLE, TABLE_HEADER, WS_OVERLAPPEDWINDOW, WS_POPUP, + }; /// **抓得住**:样式常量写错(`WS_POPUP` 少一个 0 就变成别的位,对照实验的前提就没了)。 #[test] @@ -213,19 +267,57 @@ mod tests { assert_eq!(WS_OVERLAPPEDWINDOW, 0x00CF_0000); } - /// 实验永远给出三行,顺序固定(`decorated` → `popup` → `popup+layered`)。 + /// **产品窗口不是 popup**(M116 r4 复审的核心事实,钉住它,别再写回去)。 /// - /// 抓得住:有人把分层那一行删了/调换——那样「layered 会不会被 DWM 排除」这条对照就没了。 + /// 抓得住:有人把 `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_three_styles_in_order() { + fn the_experiment_always_covers_the_same_five_rows_in_order() { let rows = controlled_experiment(); - assert_eq!(rows.len(), 3); + assert_eq!(rows.len(), 5); let labels: Vec<_> = rows.iter().map(|row| row.label).collect(); - assert_eq!(labels, ["decorated", "popup", "popup+layered"]); + 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!` 里多/少一列会让表格在报告里错位)。 @@ -243,7 +335,7 @@ mod tests { // 没造出窗口的行必须**没有**读数(不许用 0 冒充)。 let failed = ProbeRow { created: false, - ..controlled_experiment().into_iter().next().expect("三行") + ..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 index dbd6c25..4f55898 100644 --- a/crates/app-iced/src/platform/win32/transparency/tests.rs +++ b/crates/app-iced/src/platform/win32/transparency/tests.rs @@ -199,22 +199,27 @@ fn m116_corner_log_line_on_real_windows() { let os = crate::platform::current_windows_version(); let plan = plan_corner(WindowCorner::System, os, crate::Backend::Gpu); - for (label, style, ex_style) in [ - ("decorated", super::probe::WS_OVERLAPPEDWINDOW, 0u32), - ("popup", super::probe::WS_POPUP, 0u32), - ("popup+layered", super::probe::WS_POPUP, 0x0008_0000), - ] { - let hwnd = super::create_probe_window(style, ex_style); + // 直接遍历 `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] {label} window=none"); + 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!( - "[corner] {label} {} {} {}", - plan.log_line(WindowCorner::System), - super::apply_corner(hwnd, &plan).describe(), - super::dwm::style_summary(hwnd), + "{}", + 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); } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 3788819..2b4addd 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -60,7 +60,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("SettingsAppearanceCorner", "Window corners", "窗口圆角"), ("SettingsAppearanceCornerSystem", "Follow system", "跟随系统"), ("SettingsAppearanceCornerSquare", "Square", "直角"), - ("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system to round the window; whether it does, and how the edge looks, is up to the compositor. Software rendering does not change that - corners are drawn by the system, not by our renderer. Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply, and we never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理窗口外框圆角:是否圆、边缘什么样由系统合成器决定(软件渲染也一样——圆角由系统画,不由我们的渲染器画);Windows 10 保持直角;macOS 与 Linux 交给桌面自己的规则,我们不自己画圆角。"), + ("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system to round the window; whether it does, and how the edge looks, is up to the compositor. Software rendering does not change that - corners are drawn by the system, not by our renderer. While window transparency is below 100%, Windows does not round the window; the corners come back at 100%. Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply, and we never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理窗口外框圆角:是否圆、边缘什么样由系统合成器决定(软件渲染也一样——圆角由系统画,不由我们的渲染器画)。开启整窗透明度(低于 100%)时,Windows 不会给窗口圆角;透明度回到 100% 圆角自动恢复。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 f6c7020..bfa8f42 100644 --- a/crates/i18n/src/catalog/settings/appearance.rs +++ b/crates/i18n/src/catalog/settings/appearance.rs @@ -21,8 +21,12 @@ macro_rules! settings_appearance_entries { SettingsAppearanceCornerSystem => ("Follow system", "跟随系统"), SettingsAppearanceCornerSquare => ("Square", "直角"), SettingsAppearanceCornerHint => ( - "On Windows 11 and Server 2025 we ask the system to round the window; whether it does, and how the edge looks, is up to the compositor. Software rendering does not change that - corners are drawn by the system, not by our renderer. Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply, and we never draw corners ourselves.", - "Windows 11 与 Windows Server 2025 上我们请求系统处理窗口外框圆角:是否圆、边缘什么样由系统合成器决定(软件渲染也一样——圆角由系统画,不由我们的渲染器画);Windows 10 保持直角;macOS 与 Linux 交给桌面自己的规则,我们不自己画圆角。" + "On Windows 11 and Server 2025 we ask the system to round the window; whether it does, and how the edge looks, is up to the compositor. Software rendering does not change that - corners are drawn by the system, not by our renderer. While window transparency is below 100%, Windows does not round the window; the corners come back at 100%. Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply, and we never draw corners ourselves.", + "Windows 11 与 Windows Server 2025 上我们请求系统处理窗口外框圆角:是否圆、边缘什么样由系统合成器决定(软件渲染也一样——圆角由系统画,不由我们的渲染器画)。开启整窗透明度(低于 100%)时,Windows 不会给窗口圆角;透明度回到 100% 圆角自动恢复。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 d2d2342..a0dc13a 100644 --- a/crates/presenter/src/prefs.rs +++ b/crates/presenter/src/prefs.rs @@ -185,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 = 完全不透明)