From 88dbf6d95545127174baf2465102d0688504a706 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 22:56:02 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(app-iced):=20M116=20=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=A4=96=E6=A1=86=E5=9C=86=E8=A7=92=E7=AD=96=E7=95=A5=EF=BC=88?= =?UTF-8?q?=E8=B7=9F=E9=9A=8F=E7=B3=BB=E7=BB=9F/=E7=9B=B4=E8=A7=92?= =?UTF-8?q?=EF=BC=89+=20DWM=20=E8=AF=BB=E5=86=99=E7=9A=84=E5=8F=97?= =?UTF-8?q?=E6=8E=A7=E5=AF=B9=E7=85=A7=E5=AE=9E=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户问「为什么窗口没有圆角」。核实:组件层圆角齐,但窗口外框**零处理**——代码里没有任何 Dwm* / SetWindowRgn。本 mission 先做实测,再按平台分开落策略。 判据(纯函数、可真值表): - `platform::plan_corner(策略 × 系统版本 × 渲染档)` → `source=dwm|square` + `excluded_reason`。 Win11 / Server 2025(build ≥ 22000)→ DWM;Win10 及更早 → 直角(不做 SetWindowRgn 毛边默认); 探测不到版本(macOS / X11 / Wayland)→ 直角 + `unknown-os`(「不承诺」而不是「做不到」)。 CPU 档在 Win11 上照样用 DWM(D36 限制的是我们自己的合成技巧,不是系统合成器)。 落点:`platform/win32/transparency.rs` 仍是全仓唯一 unsafe 文件,新增 DWM 的 FFI 与安全包装; `alpha.rs` / `dwm.rs` / `probe.rs` 三个新子模块零 unsafe、零 extern(沿 exstyle.rs 的既有形状)。 实测(本机 Server 2025 build 26100、RDP 会话断开、同进程三窗口对照): - `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 在 WS_OVERLAPPEDWINDOW / WS_POPUP / WS_POPUP|WS_EX_LAYERED(α=0.8) 上**全部成功** ⇒ 无边框与整窗 α 不会让属性设不上; - `DwmGetWindowAttribute` 同一属性三种样式**全部 E_INVALIDARG** ⇒ 本机不支持回读,与样式无关; - `DWMWA_EXTENDED_FRAME_BOUNDS − GetWindowRect`:有边框 = +0,−7,popup = +0,+0 ⇒ DWM 只给有边框窗口加不可见调整带,我们的窗口没有可画的圆角区域(结构性解释)。 - 像素(角上透明/方块)**未验**:RDP 断连导致 CopyFromScreen / gdigrab 全废。 同时修掉一个自己踩到的判据错误:`i32::unsigned_abs()` 不能用来打印 HRESULT (E_INVALIDARG 0x80070057 会被印成 0x7FF8FFA9)。现在 HRESULT 用 `hr as u32` + 严重性位, GetLastError 的 DWORD 语义保持 `unsigned_abs` 并写死注释(两者不同源,别统一)。 配置:新键 `appearance.window_corner ∈ {system, square}`(出厂 system),schema 1.5.0 只推 minor、 不写迁移:老文件缺键 = 取默认 = 跟随系统;旧版本读新文件时未知键被登记并原样写回(`RenderMode::Full`), 只在显式 `store.set` 该键时才 `UnknownKey`。 设置 UI:外观组只留两档(跟随系统 / 直角)+ 一句按平台说清的说明,文案走 i18n 中英。 日志:`[corner] corner=policy=… source=… os=… excluded_reason=… radius=system pref=… readback=… frame=… layered=…` (形状由 `corner::format_line` 的格式化单测钉住,用探针窗口真实读数构造)。 证据:DevDocs/artifacts/m116/{README.md,probe-table.md} --- crates/app-iced/src/app/appearance.rs | 11 +- crates/app-iced/src/app/update.rs | 4 + crates/app-iced/src/app/update/appearance.rs | 56 +++ crates/app-iced/src/pages/appearance/mod.rs | 8 + .../app-iced/src/pages/appearance/section.rs | 55 ++- crates/app-iced/src/pages/appearance/state.rs | 26 +- .../app-iced/src/pages/settings/appearance.rs | 2 +- .../settings/tests/appearance_section.rs | 2 +- crates/app-iced/src/platform/mod.rs | 28 +- crates/app-iced/src/platform/win32/corner.rs | 414 ++++++++++++++++ crates/app-iced/src/platform/win32/mod.rs | 10 +- .../src/platform/win32/transparency.rs | 455 +++++++++--------- .../src/platform/win32/transparency/alpha.rs | 128 +++++ .../src/platform/win32/transparency/dwm.rs | 387 +++++++++++++++ .../src/platform/win32/transparency/probe.rs | 250 ++++++++++ .../src/platform/win32/transparency/tests.rs | 221 +++++++++ crates/app-iced/src/shell/mod.rs | 14 + crates/core/src/setting/appearance/mod.rs | 18 +- crates/core/src/setting/appearance/modes.rs | 59 ++- crates/core/src/setting/appearance/tests.rs | 41 +- crates/core/src/setting/keys.rs | 19 +- crates/core/tests/setting/appearance_keys.rs | 6 +- crates/core/tests/setting/isolation_keys.rs | 5 +- .../core/tests/setting/java_install_keys.rs | 2 +- crates/design/src/shape.rs | 11 + crates/i18n/src/catalog/golden.rs | 4 + .../i18n/src/catalog/settings/appearance.rs | 25 +- crates/presenter/src/prefs.rs | 24 +- crates/presenter/src/prefs/tests.rs | 21 +- 29 files changed, 2047 insertions(+), 259 deletions(-) create mode 100644 crates/app-iced/src/app/update/appearance.rs create mode 100644 crates/app-iced/src/platform/win32/corner.rs create mode 100644 crates/app-iced/src/platform/win32/transparency/alpha.rs create mode 100644 crates/app-iced/src/platform/win32/transparency/dwm.rs create mode 100644 crates/app-iced/src/platform/win32/transparency/probe.rs create mode 100644 crates/app-iced/src/platform/win32/transparency/tests.rs diff --git a/crates/app-iced/src/app/appearance.rs b/crates/app-iced/src/app/appearance.rs index e3e59e8..0890af2 100644 --- a/crates/app-iced/src/app/appearance.rs +++ b/crates/app-iced/src/app/appearance.rs @@ -66,7 +66,12 @@ impl App { match change.key() { // 两个只读下层键:走 schema 指令(`SettingsBridge` 的结构里没有它们)。 - Some(_) if matches!(change, Change::WindowOpacity(_) | Change::PaletteSource(_)) => { + Some(_) + if matches!( + change, + Change::WindowOpacity(_) | Change::PaletteSource(_) | Change::Corner(_) + ) => + { if let (Some(key), Some(value)) = (change.key(), change.value()) { self.dispatch(AppCommand::SetSetting { key: key.to_owned(), @@ -84,6 +89,8 @@ impl App { } else { self.log_appearance("change", None); } + // M116:圆角档位变了要**重新判定 + 重新下发**(与整窗 α 是两条独立的管线)。 + self.apply_window_corner("change"); } /// 重读配置后同步外观(热切:外部 CLI / AI 改完 1 秒内生效)。 @@ -95,6 +102,8 @@ impl App { self.rebuild_ui(); self.view_dirty = true; self.apply_window_alpha("reload"); + // M116:外部(CLI / AI)改了 `appearance.window_corner` 也要热生效。 + self.apply_window_corner("reload"); } /// 把外观写回 `launcher.json`(只改本 mission 管的字段,强调色/玻璃原样保留)。 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..ec91671 --- /dev/null +++ b/crates/app-iced/src/app/update/appearance.rs @@ -0,0 +1,56 @@ +//! M116:窗口圆角的下发落点(`impl App::apply_window_corner`)。 +//! +//! ## 为什么单独成文件 +//! +//! 圆角与整窗 α 是**两条独立的管线**,只是都必须拿到 HWND、都在同一个时点下发: +//! +//! | 管线 | 判据 | 落点 | 键 | +//! |---|---|---|---| +//! | 整窗 α(D36) | `platform::effective_window_alpha` | `SetLayeredWindowAttributes` | `appearance.window_opacity` | +//! | 圆角(M116) | `platform::plan_corner` | `DwmSetWindowAttribute` | `appearance.window_corner` | +//! +//! 合在一个函数里会让「α 变了要不要重设圆角」这种问题变成实现细节;分开之后两个判据各自可测。 +//! +//! ## 日志为什么是**一行、六栏** +//! +//! `corner=policy=… source=dwm|square os=… excluded_reason=… radius=system pref=… readback=… frame=… layered=…` +//! +//! 用户报的现象是「别的窗口都有圆角,只有我们的没有」。没有这一行,事后无法区分 +//! 「没生效」「生效了但看不见」「这台系统根本没有这个能力」;`frame` / `layered` 两栏则是 +//! 「我们被 DWM 排除」的第一嫌疑(`WS_POPUP` 无边框、`WS_EX_LAYERED` 整窗 α)的现场读数。 +//! +//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字 +//! (M116 取消了 8/12/16 固定档)。 + +use crate::platform; +use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome}; + +use super::super::state::App; + +impl App { + /// 把当前圆角策略下发到窗口,并打一行可分辨的日志。 + /// + /// 没有句柄时**也打日志**(`window=none`):不能让「没生效」只体现为屏幕上没变化。 + pub(crate) fn apply_window_corner(&self, context: &str) { + let policy = self.appearance.window_corner(); + let plan = platform::plan_corner( + policy, + platform::current_windows_version(), + self.appearance.backend, + ); + // 行的形状只有一处(`corner::format_line`),因为那里能在没有窗口的环境里被单测钉住。 + // 「我们被 DWM 排除」的第一嫌疑是无边框(`WS_POPUP`)与整窗 α(`WS_EX_LAYERED`), + // 所以 `frame=` / `layered=` 必须和圆角结论在同一行——否则以后只能猜是哪一条。 + let (outcome, style) = match self.hwnd { + Some(hwnd) => (apply_corner(hwnd, &plan).describe(), style_summary(hwnd)), + None => ( + CornerOutcome::NoWindow.describe(), + "frame=? layered=?".to_owned(), + ), + }; + eprintln!( + "{}", + platform::win32::corner::format_line(context, &plan, policy, &outcome, &style) + ); + } +} diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 4ce2137..4d9e1e6 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -80,6 +80,8 @@ pub enum Change { BgDim(f32), /// 配色来源档。 PaletteSource(unlucky_presenter::prefs::PaletteSource), + /// 窗口圆角档位(M116:跟随系统 / 直角)。 + Corner(unlucky_presenter::prefs::WindowCorner), /// 设置页切分组(左列点选)。 SelectGroup(usize), } @@ -96,6 +98,7 @@ impl Change { Self::BgFill(_) => unlucky_presenter::prefs::keys::BG_FILL, Self::BgDim(_) => unlucky_presenter::prefs::keys::BG_DIM, Self::PaletteSource(_) => unlucky_presenter::prefs::keys::PALETTE_SOURCE, + Self::Corner(_) => unlucky_presenter::prefs::keys::WINDOW_CORNER, Self::SelectGroup(_) => return None, }) } @@ -113,6 +116,7 @@ impl Change { Self::BgFill(fill) => fill.as_str().to_owned(), Self::BgDim(dim) => format!("{dim}"), Self::PaletteSource(source) => source.as_str().to_owned(), + Self::Corner(corner) => corner.as_str().to_owned(), Self::SelectGroup(_) => return None, }) } @@ -139,6 +143,10 @@ mod tests { Change::PaletteSource(PaletteSource::Wallpaper), keys::PALETTE_SOURCE, ), + ( + Change::Corner(unlucky_presenter::prefs::WindowCorner::Square), + keys::WINDOW_CORNER, + ), ]; for (change, key) in cases { assert_eq!(change.key(), Some(key), "{change:?}"); diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs index 9c87cac..f8bb34f 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`] | @@ -30,7 +31,8 @@ use unlucky_design::elevation::MaterialLayer; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; -use unlucky_presenter::prefs::{BgFill, PaletteSource}; +use unlucky_i18n::catalog::Key; +use unlucky_presenter::prefs::{BgFill, PaletteSource, WindowCorner}; use crate::app::components::inputs::{slider, text_field, Field, Slider}; use crate::pages::{frame, Message}; @@ -66,7 +68,35 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ), )); - // ② 面板透明度(CPU 档强制 1.0) + // ② 窗口圆角(M116):**只有两档**,且各平台能力不同 ⇒ 一行控件 + 一句人话说明。 + // 不做 8/12/16 档:系统不暴露圆角半径,自绘圆角在无边框 + 整窗 α 上要裁内容层 + // (会把标题栏三键与缩放热区一起裁掉)。文案不许暗示所有平台一致。 + let corner = state.window_corner(); + rows = rows.push(choice_row( + ui, + t(Key::SettingsAppearanceCorner), + vec![ + choice( + ui, + t(Key::SettingsAppearanceCornerSystem), + corner == WindowCorner::System, + Change::Corner(WindowCorner::System), + ), + choice( + ui, + t(Key::SettingsAppearanceCornerSquare), + corner == WindowCorner::Square, + Change::Corner(WindowCorner::Square), + ), + ], + )); + rows = rows.push(note_line( + ui, + t(Key::SettingsAppearanceCornerHint), + ui.color(Role::OnSurfaceVariant), + )); + + // ③ 面板透明度(CPU 档强制 1.0) let forced = state.panel_is_forced(); let panel_percent = if forced { format!("100% (forced from {:.2})", state.panel_alpha) @@ -96,7 +126,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { panel_control, )); - // ③ 背景图开关 + // ④ 背景图开关 rows = rows.push(choice_row( ui, "Background image", @@ -106,7 +136,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ], )); - // ④ 路径(文本框:受控,输入即生效) + // ⑤ 路径(文本框:受控,输入即生效) let path_field: Element<'a, Message> = text_field( ui, Field::new(&state.bg_path) @@ -120,7 +150,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { .width(Length::Fill), ); - // ⑤ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处** + // ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处** // (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。 for note in state.diagnostics() { rows = rows.push(note_line(ui, note, ui.color(Role::Error))); @@ -140,7 +170,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { let resolved = format!("Colours: {}", state.palette_level()); rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); - // ⑥ 填充 + // ⑦ 填充 rows = rows.push(choice_row( ui, "Fill", @@ -166,7 +196,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ], )); - // ⑦ 暗化 + // ⑧ 暗化 rows = rows.push(slider_row( ui, "Dim", @@ -181,7 +211,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ), )); - // ⑧ 配色来源 + // ⑨ 配色来源 rows = rows.push(choice_row( ui, "Colour source", @@ -238,6 +268,15 @@ fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> { ) } +/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。 +/// +/// 为什么是 [`Lang::En`]:本文件的既有文案是英文基线(i18n 接线是另一条 mission, +/// 见本文件头部「文案一律英文基线」)。走词表的意义是**新增条目已经有中英两套**, +/// 接线时只换这一行,不用回来重写控件。 +fn t(key: Key) -> &'static str { + unlucky_i18n::Lang::En.catalog().text(key) +} + fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> { // 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。 frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color) 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..4b242b5 --- /dev/null +++ b/crates/app-iced/src/platform/win32/corner.rs @@ -0,0 +1,414 @@ +//! M116:窗口外框圆角的**判据**(纯函数,零 `unsafe`、零 FFI、零环境读取)。 +//! +//! ## 判据为什么单独拎出来 +//! +//! 「能不能圆」= 用户策略 × 系统版本 × 渲染档,这三件事**可以被断言证明**;而 DWM 的**视觉** +//! 结果在本仓库的验证环境(RDP 断连、抓屏失效)上没有参考价值。把可测的那一半独立出来, +//! 才有「有测试的判据 + 没测试的观感」这条诚实的分界(与 [`super::super::effective_window_alpha`] +//! 同一套分法)。 +//! +//! 真正下发给窗口的动作在 [`super::transparency`](全仓唯一 `unsafe` 落点)。 +//! +//! ## 平台矩阵(真值表的出处) +//! +//! | 平台 | 系统圆角 | 我们做什么 | +//! |---|---|---| +//! | Win11 / Server 2025(build ≥ 22000) | 有(DWM 合成器画,抗锯齿) | `DWMWCP_ROUND` | +//! | Win10 及更早(build < 22000) | 无(该属性不存在) | 直角;不做 `SetWindowRgn` 毛边默认 | +//! | macOS | 有(`layer.cornerRadius`,系统/合成器决定) | **不主动加**,交给系统 | +//! | Linux X11 / Wayland | 看合成器(客户端管不了外框) | **不承诺**,直角即如实结果 | +//! +//! 后两行在本 crate 里落到同一格:探测不到 Windows 版本 ⇒ `UnknownOs` ⇒ 直角。 +//! 这是「不承诺」,不是「做不到」——两者的区别写在日志的原因码里。 + +use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD}; + +use crate::Backend; + +/// 一次「圆角该从哪来」的判定结果(M116)。 +/// +/// 只有两种来源,且**互相排斥**: +/// +/// | 来源 | 谁画圆角 | 抗锯齿 | 我们做什么 | +/// |---|---|---|---| +/// | [`CornerSource::Dwm`] | DWM 合成器 | 有(系统画) | 只下发 `DWMWA_WINDOW_CORNER_PREFERENCE` | +/// | [`CornerSource::Square`] | 没人 | — | 明确下发 `DWMWCP_DONOTROUND`,把「方」也变成可观测的选择 | +/// +/// **没有 `Self`(自绘圆角)这一档**,这是刻意的(用户定案 2026-11-03 + tower 裁定): +/// 窗口是 `decorations=false` + 自绘标题栏 + 整窗 `LWA_ALPHA`,自绘圆角意味着把内容层裁成圆角, +/// 那会把标题栏三键与边缘缩放热区一起裁掉;系统也不暴露圆角半径(无 API 可读), +/// 「固定 8/12/16」只是凭空维护一个数字。所以非 DWM 平台**就是直角**,并且日志里说明原因。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CornerSource { + /// DWM 系统圆角(Win11 / Server 2025 内核,build ≥ [`WIN11_MIN_BUILD`])。 + Dwm, + /// 直角(用户选择、系统不支持、或该平台不承诺)。 + Square, +} + +impl CornerSource { + /// 日志取值(`corner=… source=dwm|square`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Dwm => "dwm", + Self::Square => "square", + } + } + + /// 是否要求 DWM 真去画圆角。 + #[must_use] + pub const fn is_dwm(self) -> bool { + matches!(self, Self::Dwm) + } +} + +/// 为什么**不是** DWM 圆角(`None` = 就是 DWM 圆角)。日志里逐字打出来。 +/// +/// 为什么要这个枚举而不是一个 `bool`:用户报的现象是「别的窗口都有圆角,只有我们的没有」, +/// 于是「没圆角」至少有四种完全不同的原因,混成一个 `square` 以后没人能分辨 +/// 「没生效」与「生效了但看不见」(M116 任务书原话)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CornerExclusion { + /// 用户在设置里选了「直角」。 + UserSquare, + /// 系统太老:Win10 及更早(build < [`WIN11_MIN_BUILD`])——DWM 没有这个偏好属性。 + OsBuild, + /// 版本探测不出来(非 Windows / 注册表读不到 / 有东西在骗版本号)。 + UnknownOs, + /// 非 DWM 平台上的软件渲染档:圆角只能靠自绘/`SetWindowRgn` 毛边路,D36 的 + /// 「CPU 档不碰合成技巧」优先,所以退回直角。 + CpuTier, +} + +impl CornerExclusion { + /// 日志取值(`excluded_reason=…`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::UserSquare => "user", + Self::OsBuild => "os-build", + Self::UnknownOs => "unknown-os", + Self::CpuTier => "cpu-tier", + } + } +} + +/// 判定结果:来源 + 排除原因 + 平台版本(日志与测试共用)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CornerPlan { + /// 圆角从哪来。 + pub source: CornerSource, + /// `None` = 走 DWM;`Some(..)` = 走直角,并说明为什么。 + pub excluded: Option, + /// 探测到的 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 圆角:D36 限制的是**我们**做的合成技巧(面板 α / +/// 玻璃 / 模糊),而 DWM 圆角是系统合成器自己的行为,不消耗我们的渲染预算,也不违反 +/// 「CPU 档不碰透明叠加」——把 Win11 的圆角在 CPU 档砍掉等于凭空造一个没人要的降级。 +/// 反过来在非 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=popup layered=on +/// ``` +/// +/// 为什么单独拎出来:`App::apply_window_corner` 要真句柄、真 DWM 才能跑,而**这一行的形状** +/// 恰恰是「以后分辨没生效 / 生效了看不见」的唯一依据(M116 任务书原话)。形状必须在没有窗口的 +/// 环境里也能被断言——tower 2026-11-03 明确要求:不为了这行日志去构建 bin。 +/// +/// 参数里 `outcome` / `style` 由调用方给(`CornerOutcome::describe()` / `style_summary(hwnd)`), +/// 本文件不依赖 FFI 层,因此可以在非 Windows 目标上编、也能被单测覆盖。 +#[must_use] +pub fn format_line( + context: &str, + plan: &CornerPlan, + policy: WindowCorner, + outcome: &str, + style: &str, +) -> String { + format!( + "[corner] {context} {} {outcome} {style}", + plan.log_line(policy) + ) +} + +#[cfg(test)] +mod corner_tests { + use super::{plan_corner, CornerExclusion, CornerPlan, CornerSource}; + use crate::Backend; + use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD}; + + /// 本机实测版本(Server 2025 / build 26100)。 + fn win11() -> WindowsVersion { + WindowsVersion::new(10, 0, 26_100) + } + + /// Win10 22H2(build 19045):DWM 没有圆角偏好属性。 + fn win10() -> WindowsVersion { + WindowsVersion::new(10, 0, 19_045) + } + + /// **抓得住**:判定链的顺序与每一格的原因码(把 `policy`/`os`/`backend` 任一条判反都会红)。 + /// **抓不住**:DWM 到底有没有真画圆角(那要像素,见 `platform/win32` 的探针输出)。 + #[test] + fn truth_table_policy_os_backend() { + let cases = [ + // policy=Square:任何系统/后端都直角,原因是用户选的。 + ( + WindowCorner::Square, + Some(win11()), + Backend::Gpu, + CornerSource::Square, + Some(CornerExclusion::UserSquare), + ), + ( + WindowCorner::Square, + Some(win10()), + Backend::Cpu, + CornerSource::Square, + Some(CornerExclusion::UserSquare), + ), + ( + WindowCorner::Square, + None, + Backend::Auto, + CornerSource::Square, + Some(CornerExclusion::UserSquare), + ), + // policy=System + 探测不出系统:不许猜「Win11」。 + ( + WindowCorner::System, + None, + Backend::Gpu, + CornerSource::Square, + Some(CornerExclusion::UnknownOs), + ), + // Win10:直角,GPU 档原因是版本、CPU 档原因是渲染档(同一个直角,原因不同)。 + ( + WindowCorner::System, + Some(win10()), + Backend::Gpu, + CornerSource::Square, + Some(CornerExclusion::OsBuild), + ), + ( + WindowCorner::System, + Some(win10()), + Backend::Auto, + CornerSource::Square, + Some(CornerExclusion::OsBuild), + ), + ( + WindowCorner::System, + Some(win10()), + Backend::Cpu, + CornerSource::Square, + Some(CornerExclusion::CpuTier), + ), + // Win11 / Server 2025:三个后端都用 DWM(D36 只管我们自己的合成技巧)。 + ( + WindowCorner::System, + Some(win11()), + Backend::Gpu, + CornerSource::Dwm, + None, + ), + ( + WindowCorner::System, + Some(win11()), + Backend::Cpu, + CornerSource::Dwm, + None, + ), + ( + WindowCorner::System, + Some(win11()), + Backend::Auto, + CornerSource::Dwm, + None, + ), + ]; + + for (policy, os, backend, want_source, want_excluded) in cases { + let plan = plan_corner(policy, os, backend); + assert_eq!( + plan.source, want_source, + "policy={policy:?} os={os:?} backend={backend:?}" + ); + assert_eq!( + plan.excluded, want_excluded, + "policy={policy:?} os={os:?} backend={backend:?} 的原因码" + ); + } + } + + /// 边界:`WIN11_MIN_BUILD` **本身**算 Win11(`>=`,不是 `>`)。 + /// + /// 抓得住:把 `build < WIN11_MIN_BUILD` 写成 `<=`——Win11 21H2(build 22000)会静默掉成直角, + /// 而那正好是第一台 Win11 机器。 + #[test] + fn the_win11_boundary_is_inclusive() { + let at = WindowsVersion::new(10, 0, WIN11_MIN_BUILD); + assert_eq!( + plan_corner(WindowCorner::System, Some(at), Backend::Gpu).source, + CornerSource::Dwm + ); + let below = WindowsVersion::new(10, 0, WIN11_MIN_BUILD - 1); + assert_eq!( + plan_corner(WindowCorner::System, Some(below), Backend::Gpu).source, + CornerSource::Square, + "build 21999 = Win10 22H2 家族末尾,不能算 Win11" + ); + } + + /// 日志行的形状:四个字段都在,且 `excluded_reason=none` 与 `reason=user` 不许混。 + /// + /// 为什么要钉这一条:这行是「后来的人分辨没生效 / 生效了看不见」的唯一依据(任务书原话), + /// 少一栏就等于把这条线索丢掉。 + #[test] + fn the_log_line_carries_every_field() { + let dwm = plan_corner(WindowCorner::System, Some(win11()), Backend::Gpu) + .log_line(WindowCorner::System); + assert!(dwm.starts_with("corner=policy=system"), "{dwm}"); + assert!(dwm.contains("source=dwm"), "{dwm}"); + assert!(dwm.contains("os=10.0.26100"), "{dwm}"); + assert!(dwm.contains("excluded_reason=none"), "{dwm}"); + assert!(dwm.contains("radius=system"), "{dwm}"); + + let square = plan_corner(WindowCorner::Square, Some(win11()), Backend::Gpu) + .log_line(WindowCorner::Square); + assert!(square.contains("source=square"), "{square}"); + assert!(square.contains("excluded_reason=user"), "{square}"); + + let unknown: CornerPlan = plan_corner(WindowCorner::System, None, Backend::Gpu); + assert!(unknown + .log_line(WindowCorner::System) + .contains("os=unknown")); + } +} + +/// **日志行的形状**,用 M116 探针窗口的**真实读数**构造(`DevDocs/artifacts/m116/probe-table.md`)。 +/// +/// 抓得住:字段被删/改名/顺序错乱(`excluded_reason` 少一栏、`radius=system` 丢了、 +/// `frame` / `layered` 被挪出这一行)——这些都会让「没生效 vs 生效了看不见」的区分重新变成猜谜。 +/// 抓不住:产品进程里那一行到底长什么样(本 mission 没构建 bin;合并态由 tower 编 `--bins` 补)。 +#[test] +fn the_log_line_shape_is_pinned_with_the_measured_readings() { + use super::{format_line, plan_corner, CornerPlan}; + use crate::Backend; + use unlucky_presenter::prefs::WindowsVersion; + + // 本机实测:Server 2025 / build 26100;读回 `E_INVALIDARG`;popup 窗口 layered=on。 + let os = WindowsVersion::new(10, 0, 26_100); + let plan: CornerPlan = plan_corner(WindowCorner::System, Some(os), Backend::Gpu); + let line = format_line( + "boot", + &plan, + WindowCorner::System, + "pref=2 readback=? read_hr=0x80070057", + "frame=popup layered=on", + ); + assert_eq!( + line, + "[corner] boot corner=policy=system source=dwm os=10.0.26100 excluded_reason=none radius=system pref=2 readback=? read_hr=0x80070057 frame=popup layered=on" + ); + + // 设置里选「直角」:原因码必须从 `none` 变成 `user`,其余字段不动。 + let square = format_line( + "change", + &plan_corner(WindowCorner::Square, Some(os), Backend::Gpu), + WindowCorner::Square, + "pref=1 readback=? read_hr=0x80070057", + "frame=popup layered=off", + ); + assert!( + square.contains("corner=policy=square source=square"), + "{square}" + ); + assert!(square.contains("excluded_reason=user"), "{square}"); + assert!(square.contains("frame=popup layered=off"), "{square}"); + + // Win10:原因码是 `os-build`(GPU 档)/ `cpu-tier`(CPU 档),来源仍是 square。 + let win10 = WindowsVersion::new(10, 0, 19_045); + let os_build = + plan_corner(WindowCorner::System, Some(win10), Backend::Gpu).log_line(WindowCorner::System); + assert!( + os_build.contains("source=square") && os_build.contains("excluded_reason=os-build"), + "{os_build}" + ); + let cpu = + plan_corner(WindowCorner::System, Some(win10), Backend::Cpu).log_line(WindowCorner::System); + assert!(cpu.contains("excluded_reason=cpu-tier"), "{cpu}"); + + // 探测不到版本(macOS / X11 / Wayland 落到同一格):`unknown-os`,不许猜 Win11。 + let unknown = + plan_corner(WindowCorner::System, None, Backend::Gpu).log_line(WindowCorner::System); + assert!( + unknown.contains("os=unknown") && unknown.contains("excluded_reason=unknown-os"), + "{unknown}" + ); +} 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..82eac7f 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,227 @@ extern "system" { #[cfg(target_pointer_width = "32")] #[link_name = "SetWindowLongW"] fn SetWindowLongPtrW(hwnd: isize, index: i32, new_value: i32) -> i32; + + /// `GetWindowRect`:窗口矩形的屏幕坐标(**含** DWM 加出来的不可见边框)。 + /// + /// 与 `DWMWA_EXTENDED_FRAME_BOUNDS` 对照,是 M116 探针里「DWM 到底给这个窗口加了多宽的 + /// 边框」这条差值的来源:差值 ≈ 系统认为的边框带宽度。 + fn GetWindowRect(hwnd: isize, rect: *mut WinRect) -> i32; + + /// `CreateWindowExW`:M116 的**受控对照实验**要用它造三种样式的窗口(同进程)。 + /// + /// 只用系统自带类 `STATIC`(不需要 `RegisterClassW`,也就没有窗口过程要写)。 + fn CreateWindowExW( + ex_style: u32, + class: *const u16, + name: *const u16, + style: u32, + x: i32, + y: i32, + width: i32, + height: i32, + parent: isize, + menu: isize, + instance: isize, + param: *mut core::ffi::c_void, + ) -> isize; + + /// `DestroyWindow`:探针窗口用完即毁(不泄漏 HWND)。 + fn DestroyWindow(hwnd: isize) -> i32; + + /// `ShowWindow`:DWM 只管理**可见**窗口,所以探针窗口必须真显示一次才有读数。 + fn ShowWindow(hwnd: isize, command: i32) -> i32; +} + +/// `RECT`(`GetWindowRect` 的 out 参数)。字段顺序照 Win32 头文件,`repr(C)` 保证布局。 +#[cfg(target_os = "windows")] +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub(super) struct WinRect { + /// 左 + pub left: i32, + /// 上 + pub top: i32, + /// 右 + pub right: i32, + /// 下 + pub bottom: i32, } #[cfg(target_os = "windows")] -fn set_layered(hwnd: isize, byte: u8) -> AlphaOutcome { +#[link(name = "dwmapi")] +extern "system" { + /// `DwmSetWindowAttribute`:M116 圆角的唯一落点(`DWMWA_WINDOW_CORNER_PREFERENCE`)。 + /// + /// 返回 `HRESULT`:`S_OK`(0) = 成功;属性不被这个 Windows 版本认识时是 `E_INVALIDARG` + /// (`0x8007_0057`)——Win10 上就是这个返回值,**不是**「设了但没效果」。 + fn DwmSetWindowAttribute( + hwnd: isize, + attribute: u32, + value: *const core::ffi::c_void, + size: u32, + ) -> i32; + + /// `DwmGetWindowAttribute`:读回 DWM 侧的属性(探针与日志的证据来源)。 + fn DwmGetWindowAttribute( + hwnd: isize, + attribute: u32, + value: *mut core::ffi::c_void, + size: u32, + ) -> i32; +} + +/// 写一个 `u32` 的 DWM 属性,返回 `HRESULT`(0 = `S_OK`)。 +#[cfg(target_os = "windows")] +pub(super) fn dwm_set_u32(hwnd: isize, attribute: u32, value: u32) -> i32 { + // SAFETY: `value` 是栈上的 POD,指针只在调用期间有效,`size` 与它逐字节一致; + // DWM 只按值读取,不保留指针。 + unsafe { + DwmSetWindowAttribute( + hwnd, + attribute, + (&raw const value).cast::(), + u32::try_from(std::mem::size_of::()).unwrap_or(4), + ) + } +} + +/// `HRESULT` 的成功判据:**看严重性位**(bit 31),不是「等于 0」。 +/// +/// Win32 里成功码不只有 `S_OK`(0):`S_FALSE`(1) 与各种 `S_*` / 信息级码都是成功。 +/// M116 实测:本机 DWM 回读偏好返回 `0x7FF8FFA9`(正数),`hr == 0` 判据会把它读成失败, +/// 于是「属性接受了吗」变成谜。本函数把这条判据收成一处,测试钉住正负两侧。 +#[must_use] +pub const fn hresult_ok(hr: i32) -> bool { + hr >= 0 +} + +/// 读一个 `u32` 的 DWM 属性**并保留 `HRESULT`**(M116 探针要它:读不回来时得知道为什么)。 +#[cfg(target_os = "windows")] +pub(super) fn dwm_get_u32_hr(hwnd: isize, attribute: u32) -> (Option, u32) { + 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), + ) + }; + (hr == 0).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`)。探针用它证明「无边框 = `WS_POPUP`」这条前提。 +#[cfg(target_os = "windows")] +pub(super) fn window_style(hwnd: isize) -> isize { + const GWL_STYLE: i32 = -16; + // SAFETY: 同 `raw_ex_style`:只读一个窗口属性,按值传参、不持有指针。 + unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } +} + +#[cfg(target_os = "windows")] +pub(super) fn set_layered(hwnd: isize, byte: u8) -> AlphaOutcome { // SAFETY: 四个参数都是按值传入的 POD;`flags` 只带 LWA_ALPHA,于是系统忽略 `color_key` // (传 0 与之无关)。句柄由调用方给出:无效句柄只会让 Win32 返回 0(走下面的 Failed 分支), // 不会造成未定义行为——本调用不持有指针,也没有释放语义。 let ok = unsafe { SetLayeredWindowAttributes(hwnd, 0, byte, LWA_ALPHA) }; if ok == 0 { + // ⚠️ **这里的 `unsigned_abs()` 是有意的,别跟 DWM 那几处「统一」**(tower 裁定 2026-11-03): + // + // - 这里取的是 `GetLastError()` 的 **DWORD**(`raw_os_error()` 给的是被塞进 `i32` 的 DWORD, + // 例如 `87` / `1400`),它不是 HRESULT,没有严重性位;`unsigned_abs` 只是把 + // 「高位被当符号」的那点噪声抹掉,语义仍是「错误号」。 + // - `DwmGet/SetWindowAttribute` 返回的是 **HRESULT**:判成功看 bit 31(`hresult_ok`), + // 打印必须用 `hr as u32` 的原始位——用 `unsigned_abs()` 会把 `E_INVALIDARG` + // (`0x8007_0057`)印成 `0x7FF8_FFA9`(M116 实测踩过,见 `dwm_get_u32_hr` 的注释)。 + // + // 两种「错误号」不同源,所以打印方式也不该相同。这条由 + // `tests::dword_error_codes_and_hresults_are_printed_differently` 钉住。 let code = std::io::Error::last_os_error() .raw_os_error() .unwrap_or(0) @@ -278,108 +384,9 @@ fn set_layered(hwnd: isize, byte: u8) -> AlphaOutcome { } #[cfg(not(target_os = "windows"))] -fn set_layered(_hwnd: isize, _byte: u8) -> AlphaOutcome { +pub(super) fn set_layered(_hwnd: isize, _byte: u8) -> AlphaOutcome { AlphaOutcome::Unsupported } #[cfg(test)] -mod tests { - use super::{ - alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE, - LWA_ALPHA, WS_EX_LAYERED, - }; - use unlucky_presenter::prefs::WINDOW_ALPHA_MIN; - - #[test] - fn layered_constants_match_the_win32_header() { - // 抓得住:常量写错(例如把 GWL_EXSTYLE 写成 -16 = GWL_STYLE,或 WS_EX_LAYERED 少写一个 0)。 - // 抓不住:这些值在真实窗口上是否真的生效(那要靠真机日志,见 app 侧)。 - assert_eq!(WS_EX_LAYERED, 0x0008_0000); - assert_eq!(GWL_EXSTYLE, -20); - } - - #[test] - fn ensure_layered_refuses_a_missing_or_invalid_window() { - // 句柄 0 = 还没拿到窗口:不许「假设成功」。 - assert!(!ensure_layered(0), "句柄 0 必须返回 false"); - // 1 号句柄永远无效:读不到扩展样式,也就加不上这一位。 - assert!(!ensure_layered(1), "无效句柄必须返回 false(不 panic)"); - } - - #[test] - fn alpha_byte_quantizes_upward_never_below_the_floor() { - assert_eq!(alpha_byte(1.0), 255); - assert_eq!(alpha_byte(0.5), 128, "0.5×255=127.5 → 向上取整 128"); - assert_eq!( - alpha_byte(WINDOW_ALPHA_MIN), - 21, - "0.08×255=20.4 → 向上取整 21;用 round 会得到 20,实际下发 20/255=0.0784 低于 D36 下限" - ); - // 漏过预处理的坏值也不会变成「穿透」:一律落到下限字节。 - for bad in [0.0, -3.0, f64::NAN] { - assert_eq!( - alpha_byte(bad), - alpha_byte(WINDOW_ALPHA_MIN), - "{bad} 应取下限" - ); - } - assert_eq!( - LWA_ALPHA, 0x2, - "0x2 就是 LWA_ALPHA;写成 0x1 会变成色键、等于没设 α" - ); - } - - #[test] - fn applied_alpha_is_never_below_the_floor() { - // 真正生效的 α 是 `bAlpha / 255`,不是传进来的 f64。这条把「改用 round」的行为钉死: - // round 下 0.08 → 20 → 20/255 = 0.0784 < 0.08 → 这里红。 - for requested in [WINDOW_ALPHA_MIN, 0.081, 0.1, 0.33, 0.5, 0.77, 0.999, 1.0] { - let byte = alpha_byte(requested); - let applied = f64::from(byte) / 255.0; - assert!( - applied >= WINDOW_ALPHA_MIN - 1e-12, - "requested={requested} → byte={byte} → 实际 α={applied},低于下限 {WINDOW_ALPHA_MIN}" - ); - assert!( - applied <= 1.0, - "requested={requested} → 实际 α={applied} 超过 1.0" - ); - } - } - - #[test] - fn missing_or_opaque_alpha_never_touches_the_window() { - for none in [None, Some(0.0), Some(-0.5), Some(f64::NAN)] { - assert_eq!( - apply_window_alpha(1234, none), - AlphaOutcome::Rejected(AlphaRejection::NoAlpha), - "{none:?} 应被拒绝(整窗穿透不许发生)" - ); - } - // α = 1.0:两个平台都返回 Opaque —— 正好证明「不调 FFI」不是靠 Unsupported 混过去的。 - assert_eq!(apply_window_alpha(1234, Some(1.0)), AlphaOutcome::Opaque); - // 合法 α 但没有句柄:拒绝,绝不下发。 - assert_eq!( - apply_window_alpha(0, Some(0.5)), - AlphaOutcome::Rejected(AlphaRejection::NoWindow) - ); - } - - #[cfg(target_os = "windows")] - #[test] - fn invalid_handle_reports_a_win32_failure_instead_of_panicking() { - // 用 1 号句柄(永远无效)真调一次 API:验的是**返回值语义**,不是观感。 - match apply_window_alpha(1, Some(0.5)) { - AlphaOutcome::Failed { code } => { - assert_eq!(code, 1400, "ERROR_INVALID_WINDOW_HANDLE") - } - other => panic!("无效句柄应报 Failed,实际 {other:?}"), - } - } - - #[cfg(not(target_os = "windows"))] - #[test] - fn non_windows_stub_reports_unsupported() { - assert_eq!(apply_window_alpha(1, Some(0.5)), AlphaOutcome::Unsupported); - } -} +mod tests; 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..1b956c4 --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency/dwm.rs @@ -0,0 +1,387 @@ +//! DWM 圆角(M116):把 [`CornerPlan`](super::super::corner::CornerPlan) 落成一次属性下发。 +//! +//! ## 本文件零 `unsafe`、零 `extern` +//! +//! `DwmSetWindowAttribute` / `DwmGetWindowAttribute` 的声明与调用点在 [`super`] +//! (全仓唯一 `unsafe` 落点);这里只做「哪一档 → 哪个属性值」的映射、`HRESULT` 解释与回读。 +//! +//! ## 为什么「直角」也要真的下发一次 +//! +//! `DWMWCP_DONOTROUND` 是**显式**的直角请求。不下发也能得到直角(Win10 就没有这个属性), +//! 但那样日志里「方」与「没生效」长得一模一样。下发 + 回读之后, +//! `corner=… source=square excluded_reason=user` 与 `excluded_reason=os-build` 是两条 +//! 可区分的事实,而不是一句猜测。 +//! +//! ## 回读为什么是证据而不是自证 +//! +//! `DwmSetWindowAttribute` 返回 `S_OK` 只说明「参数被接受了」。属性值由 DWM 保存, +//! 回读拿到的是 **DWM 侧的当前值**——它能抓住「设了 A 属性、读回还是旧值」这类事 +//! (比如把 `DWMWA_WINDOW_CORNER_PREFERENCE` 写成别的号,回读会给出 `None` 或旧值)。 + +/// `DWMWA_WINDOW_CORNER_PREFERENCE`:窗口圆角偏好的属性号(Win11 / Server 2025,build ≥ 22000)。 +pub const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 34; +/// `DWMWCP_DONOTROUND`:显式直角。 +pub const DWMWCP_DONOTROUND: u32 = 1; +/// `DWMWCP_ROUND`:显式圆角(半径由系统决定,**不暴露**给应用)。 +pub const DWMWCP_ROUND: u32 = 2; +/// `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS`:可见边框厚度(像素,Win11 起)。 +/// +/// 这是本机**唯一**能拿到的「DWM 到底把这个窗口当不当有框窗口」的数值信号: +/// 圆角窗口带有一条 1px(按 DPI 缩放)的可见边框,直角窗口为 0。 +pub const DWMWA_VISIBLE_FRAME_BORDER_THICKNESS: u32 = 37; +/// `DWMWA_EXTENDED_FRAME_BOUNDS`:DWM 认为的窗口边界(**含**不可见的调整带)。 +pub const DWMWA_EXTENDED_FRAME_BOUNDS: u32 = 9; + +/// 一次圆角下发的结局(日志与测试共用)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CornerOutcome { + /// 非 Windows:空实现,什么都没做。 + UnsupportedPlatform, + /// 句柄为 0:还没拿到窗口,或窗口已销毁。 + NoWindow, + /// 已下发且回读一致。`readback == None` 表示**这一版 DWM 不接受回读** + /// (属性不被认识时 `DwmGetWindowAttribute` 返回 `E_INVALIDARG`),此时只有 `S_OK` 作证。 + Applied { + /// 下发下去的偏好值([`DWMWCP_ROUND`] / [`DWMWCP_DONOTROUND`])。 + preference: u32, + /// DWM 侧回读到的值(`None` = 读不回来)。 + readback: Option, + /// 回读那一次的 `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) + } +} + +/// 读可见边框厚度(像素)。这是本机唯一能拿到的「DWM 把窗口当不当有框窗口」的数值信号。 +#[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`):证明「无边框 = `WS_POPUP`」这条前提。 +#[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` 位:无边框窗口(winit 在 `decorations = false` 下给的形状)。 +pub const WS_POPUP: isize = 0x8000_0000_u32 as isize; + +/// 帧样式的一句话标签(纯函数,日志用):`popup` / `overlapped` / `other`。 +/// +/// 为什么要它:用户的现象是「别的窗口有圆角,我们的没有」,而**我们被排除的第一嫌疑**就是 +/// `WS_POPUP`(无边框)。把它打进日志,事后才能把「这台机器的 DWM 对 popup 窗口不套圆角」 +/// 与「属性没设上」分开。 +#[must_use] +pub const fn frame_label(style: isize) -> &'static str { + const WS_OVERLAPPEDWINDOW_MASK: isize = 0x00CF_0000; + if style & WS_POPUP != 0 { + "popup" + } else if style & WS_OVERLAPPEDWINDOW_MASK == WS_OVERLAPPEDWINDOW_MASK { + "overlapped" + } else { + "other" + } +} + +/// 分层位的一句话标签(纯函数,日志用):`on` / `off`。 +#[must_use] +pub const fn layered_label(ex_style: isize) -> &'static str { + if ex_style & super::WS_EX_LAYERED != 0 { + "on" + } else { + "off" + } +} + +/// 一个窗口的样式摘要(`frame=popup layered=on`);读不到时如实写 `frame=? layered=?`。 +#[must_use] +pub fn style_summary(hwnd: isize) -> String { + let frame = window_style(hwnd).map_or("?", frame_label); + let layered = window_ex_style(hwnd).map_or("?", layered_label); + format!("frame={frame} layered={layered}") +} + +#[cfg(test)] +mod tests { + use super::{ + apply_corner, corner_preference_hr, preference_for, CornerOutcome, + DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_DONOTROUND, + DWMWCP_ROUND, + }; + use crate::platform::win32::corner::{CornerPlan, CornerSource}; + use unlucky_presenter::prefs::WindowsVersion; + + /// **抓得住**:属性号写错(34 写成 33 / 37 写成 36 —— 这两个都在 Win11 上存在,改错不会崩, + /// 只会静默读出别的东西)、两个偏好值互换。 + /// **抓不住**:这些值在真窗口上是否真的生效(那要探针读数,见 `probe` 与报告)。 + #[test] + fn attribute_numbers_follow_the_win32_header() { + assert_eq!(DWMWA_WINDOW_CORNER_PREFERENCE, 34); + assert_eq!(DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, 37); + assert_eq!(DWMWCP_ROUND, 2); + assert_eq!(DWMWCP_DONOTROUND, 1); + } + + /// **抓得住**:`preference_for` 把圆角/直角映射反了(这一格反了就是「设置里选直角反而更圆」)。 + #[test] + fn the_preference_mapping_cannot_be_swapped() { + assert_eq!(preference_for(CornerSource::Dwm), DWMWCP_ROUND); + assert_eq!(preference_for(CornerSource::Square), DWMWCP_DONOTROUND); + } + + /// 无效句柄:**不下发**、也不 panic。句柄 0 走 `NoWindow`(还没拿到窗口 ≠ 平台不支持)。 + #[test] + fn a_missing_window_is_reported_not_guessed() { + let plan = CornerPlan { + source: CornerSource::Dwm, + excluded: None, + os: Some(WindowsVersion::new(10, 0, 26_100)), + }; + assert_eq!(apply_corner(0, &plan), CornerOutcome::NoWindow); + assert_eq!(corner_preference_hr(0).0, None); + // 1 号句柄永远无效:非 Windows 上是「平台不支持」,Windows 上是 DWM 的失败码, + // 两种都不许表现成「成功」。 + let outcome = apply_corner(1, &plan); + assert!( + matches!( + outcome, + CornerOutcome::Failed { .. } + | CornerOutcome::Unsupported { .. } + | CornerOutcome::UnsupportedPlatform + ), + "无效句柄应报失败,实际 {outcome:?}" + ); + } + + /// 日志尾巴三态可区分:设了 / 设了但读不回 / 属性不支持。 + #[test] + fn the_log_tail_separates_applied_from_unsupported() { + let applied = CornerOutcome::Applied { + preference: DWMWCP_ROUND, + readback: Some(DWMWCP_ROUND), + read_hresult: 0, + }; + assert!( + applied.describe().starts_with("pref=2 readback=2"), + "{applied:?}" + ); + + let blind = CornerOutcome::Applied { + preference: DWMWCP_ROUND, + readback: None, + read_hresult: 0x8000_4005, + }; + assert!( + blind.describe().contains("readback=?"), + "{}", + blind.describe() + ); + + let old = CornerOutcome::Unsupported { + hresult: 0x8007_0057, + }; + assert!( + old.describe().contains("attribute unsupported"), + "{}", + old.describe() + ); + assert!(CornerOutcome::UnsupportedPlatform + .describe() + .contains("platform=unsupported")); + } +} + +/// **抓得住**:`frame_label` 把 `WS_POPUP` 与 `WS_OVERLAPPEDWINDOW` 判反(那会让日志把 +/// 「我们自己的窗口」写成「普通窗口」,这行日志的全部价值就是分辨这个)。 +/// **抓不住**:真窗口上到底置了哪一位(那属于运行时读数)。 +#[test] +fn the_frame_label_separates_popup_from_a_decorated_window() { + assert_eq!(WS_POPUP, 0x8000_0000_u32 as isize); + assert_eq!(frame_label(WS_POPUP | 0x1000_0000), "popup"); + assert_eq!(frame_label(0x00CF_0000), "overlapped"); + assert_eq!(frame_label(0), "other"); +} + +/// 分层位标签:只认 `WS_EX_LAYERED`,别把 `WS_EX_TRANSPARENT` 之类的邻居混进来。 +#[test] +fn the_layered_label_reads_the_right_bit() { + let layered = crate::platform::win32::transparency::WS_EX_LAYERED; + assert_eq!(layered_label(layered), "on"); + assert_eq!(layered_label(WS_POPUP), "off"); + assert_eq!(layered_label(0), "off"); +} + +/// 读不到句柄时摘要不许编:`frame=? layered=?`(「不知道」与「没有」是两件事)。 +#[test] +fn the_style_summary_never_guesses() { + assert_eq!(style_summary(0), "frame=? layered=?"); +} 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..da97601 --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency/probe.rs @@ -0,0 +1,250 @@ +//! M116 的**受控对照实验**:同进程里造三种窗口样式,逐个读 DWM 属性。 +//! +//! ## 为什么需要它 +//! +//! 用户的观察是「同一台机器上别的窗口都有圆角,只有我们的没有」。于是「没圆角」至少有三种 +//! 可能:①这是 DWM 的默认行为(我们没申请);②我们用什么样式把它 opt-out 了 +//! (`decorations=false` ⇒ `WS_POPUP`,以及 D36 的整窗 `WS_EX_LAYERED`);③属性根本设不上。 +//! +//! 只在自己的窗口上读一次,分不清 ①②③。所以这里**同一进程**里造三个窗口,除「样式」外 +//! 条件全相同: +//! +//! | 标签 | 样式 | 对应现实 | +//! |---|---|---| +//! | `decorated` | `WS_OVERLAPPEDWINDOW` | 用户说的「别的窗口」——Win11 上就是圆的 | +//! | `popup` | `WS_POPUP` | winit 在 `decorations = false` 下给的样式 | +//! | `popup+layered` | `WS_POPUP` + `WS_EX_LAYERED` + `LWA_ALPHA 205` | 本产品窗口的完整形状(D36) | +//! +//! ## 这个实验能证明什么、不能证明什么 +//! +//! **能**:属性层能不能开(`DwmSetWindowAttribute` 的 `HRESULT`)、开完 DWM 侧回读是多少、 +//! 三种样式之间 `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS` 与 +//! `DWMWA_EXTENDED_FRAME_BOUNDS - GetWindowRect` 的数值差是否随样式变化。 +//! +//! **不能**:角上到底是不是透明的(那要合成输出像素,本机 RDP 断连拿不到)。所以本文件 +//! 的读数只回答「属性层」,观感归真机验收——报告里逐条标注。 + +#[cfg(target_os = "windows")] +use super::alpha::{apply_window_alpha, ensure_layered}; +use super::dwm::{ + corner_preference_hr, extended_frame_bounds, set_preference, visible_border_thickness, + window_rect, window_style, CornerOutcome, DWMWCP_DONOTROUND, DWMWCP_ROUND, +}; + +/// `WS_POPUP`:无边框弹出窗口(winit 在 `decorations = false` 下用它)。 +pub const WS_POPUP: u32 = 0x8000_0000; +/// `WS_OVERLAPPEDWINDOW`:普通有边框窗口(对照组的「别的窗口」)。 +pub const WS_OVERLAPPEDWINDOW: u32 = 0x00CF_0000; +/// `WS_EX_LAYERED` 的 `u32` 形态(`create_probe_window` 的参数类型)。 +const WS_EX_LAYERED_U32: u32 = 0x0008_0000; + +/// 实验里的一行(一个窗口样式)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProbeRow { + /// 这一行是什么样式(见模块文档的表)。 + pub label: &'static str, + /// 创建时请求的样式位。 + pub style: u32, + /// 创建时请求的扩展样式位。 + pub ex_style: u32, + /// 读回的 `GWL_STYLE`(证明请求的样式真的生效了)。 + pub style_readback: Option, + /// 什么都没设时 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 返回空表)。 +/// +/// 每个窗口用完立刻销毁:这是诊断路径,不许留 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), + ] { + rows.push(run_one(label, style, ex_style)); + } + rows +} + +/// 一个样式的完整读数。`created = false` 时其余字段全 `None`(**不许**拿 0 冒充读数)。 +#[cfg(target_os = "windows")] +fn run_one(label: &'static str, style: u32, ex_style: u32) -> ProbeRow { + use super::{create_probe_window, destroy_probe_window, show_probe_window}; + + let hwnd = create_probe_window(style, ex_style); + if hwnd == 0 { + return empty_row(label, style, ex_style); + } + show_probe_window(hwnd); + // D36 的整窗 α:只有 layered 那一行才有意义(`apply_window_alpha` 对 α=1 不下发)。 + if ex_style & WS_EX_LAYERED_U32 != 0 { + ensure_layered(hwnd); + let _ = apply_window_alpha(hwnd, Some(0.8)); + } + + let (preference_default, preference_read_hresult) = corner_preference_hr(hwnd); + let border_default = visible_border_thickness(hwnd); + let set_round = set_preference(hwnd, DWMWCP_ROUND); + let border_round = visible_border_thickness(hwnd); + let set_square = set_preference(hwnd, DWMWCP_DONOTROUND); + let border_square = visible_border_thickness(hwnd); + + let frame_delta = + window_rect(hwnd) + .zip(extended_frame_bounds(hwnd)) + .map(|(plain, extended)| { + ( + (extended.0 - plain.0) + (extended.2 - plain.2), + (extended.1 - plain.1) + (extended.3 - plain.3), + ) + }); + + let row = ProbeRow { + label, + style, + ex_style, + style_readback: window_style(hwnd), + preference_default, + preference_read_hresult, + set_round, + set_square, + border_default, + border_round, + border_square, + frame_delta, + created: true, + }; + destroy_probe_window(hwnd); + row +} + +/// 非 Windows:造不出 Win32 窗口,整行标记 `created = false`(不许编读数)。 +#[cfg(not(target_os = "windows"))] +fn run_one(label: &'static str, style: u32, ex_style: u32) -> ProbeRow { + empty_row(label, style, ex_style) +} + +fn empty_row(label: &'static str, style: u32, ex_style: u32) -> ProbeRow { + ProbeRow { + label, + style, + ex_style, + style_readback: None, + preference_default: None, + preference_read_hresult: 0, + set_round: CornerOutcome::UnsupportedPlatform, + set_square: CornerOutcome::UnsupportedPlatform, + border_default: None, + border_round: None, + border_square: None, + frame_delta: None, + created: false, + } +} + +#[cfg(test)] +mod tests { + use super::{controlled_experiment, ProbeRow, TABLE_HEADER, WS_OVERLAPPEDWINDOW, WS_POPUP}; + + /// **抓得住**:样式常量写错(`WS_POPUP` 少一个 0 就变成别的位,对照实验的前提就没了)。 + #[test] + fn the_style_constants_match_the_win32_header() { + assert_eq!(WS_POPUP, 0x8000_0000); + assert_eq!(WS_OVERLAPPEDWINDOW, 0x00CF_0000); + } + + /// 实验永远给出三行,顺序固定(`decorated` → `popup` → `popup+layered`)。 + /// + /// 抓得住:有人把分层那一行删了/调换——那样「layered 会不会被 DWM 排除」这条对照就没了。 + /// 抓不住:读数本身(那要么真机跑,要么看报告里的表)。 + #[test] + fn the_experiment_always_covers_three_styles_in_order() { + let rows = controlled_experiment(); + assert_eq!(rows.len(), 3); + let labels: Vec<_> = rows.iter().map(|row| row.label).collect(); + assert_eq!(labels, ["decorated", "popup", "popup+layered"]); + assert_eq!(rows[0].style, WS_OVERLAPPEDWINDOW); + assert_eq!(rows[1].style, WS_POPUP); + assert_eq!(rows[2].ex_style & 0x0008_0000, 0x0008_0000); + } + + /// 表头与每行的竖线数一致(`format!` 里多/少一列会让表格在报告里错位)。 + #[test] + fn the_table_columns_line_up() { + let header_pipes = TABLE_HEADER.matches('|').count(); + for row in controlled_experiment() { + let line = row.describe(); + assert_eq!( + line.matches('|').count(), + header_pipes, + "列数不一致:{line}" + ); + } + // 没造出窗口的行必须**没有**读数(不许用 0 冒充)。 + let failed = ProbeRow { + created: false, + ..controlled_experiment().into_iter().next().expect("三行") + }; + assert!(failed.describe().contains("FAILED")); + } +} 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..f631de6 --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency/tests.rs @@ -0,0 +1,221 @@ +//! `transparency` 的测试(M116 从 `transparency.rs` 搬出来:那个文件要留行数给 DWM 的 FFI)。 +//! +//! **本文件零 `unsafe`、零 `extern`**:只调父模块的安全包装。别把 FFI 写进来—— +//! 「全仓唯一允许 unsafe 的文件」是 `transparency.rs`,不是它下面的每个文件。 + +use super::{ + alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE, + LWA_ALPHA, WS_EX_LAYERED, +}; +use unlucky_presenter::prefs::WINDOW_ALPHA_MIN; + +#[test] +fn layered_constants_match_the_win32_header() { + // 抓得住:常量写错(例如把 GWL_EXSTYLE 写成 -16 = GWL_STYLE,或 WS_EX_LAYERED 少写一个 0)。 + // 抓不住:这些值在真实窗口上是否真的生效(那要靠真机日志,见 app 侧)。 + assert_eq!(WS_EX_LAYERED, 0x0008_0000); + assert_eq!(GWL_EXSTYLE, -20); +} + +#[test] +fn ensure_layered_refuses_a_missing_or_invalid_window() { + // 句柄 0 = 还没拿到窗口:不许「假设成功」。 + assert!(!ensure_layered(0), "句柄 0 必须返回 false"); + // 1 号句柄永远无效:读不到扩展样式,也就加不上这一位。 + assert!(!ensure_layered(1), "无效句柄必须返回 false(不 panic)"); +} + +#[test] +fn alpha_byte_quantizes_upward_never_below_the_floor() { + assert_eq!(alpha_byte(1.0), 255); + assert_eq!(alpha_byte(0.5), 128, "0.5×255=127.5 → 向上取整 128"); + assert_eq!( + alpha_byte(WINDOW_ALPHA_MIN), + 21, + "0.08×255=20.4 → 向上取整 21;用 round 会得到 20,实际下发 20/255=0.0784 低于 D36 下限" + ); + // 漏过预处理的坏值也不会变成「穿透」:一律落到下限字节。 + for bad in [0.0, -3.0, f64::NAN] { + assert_eq!( + alpha_byte(bad), + alpha_byte(WINDOW_ALPHA_MIN), + "{bad} 应取下限" + ); + } + assert_eq!( + LWA_ALPHA, 0x2, + "0x2 就是 LWA_ALPHA;写成 0x1 会变成色键、等于没设 α" + ); +} + +#[test] +fn applied_alpha_is_never_below_the_floor() { + // 真正生效的 α 是 `bAlpha / 255`,不是传进来的 f64。这条把「改用 round」的行为钉死: + // round 下 0.08 → 20 → 20/255 = 0.0784 < 0.08 → 这里红。 + for requested in [WINDOW_ALPHA_MIN, 0.081, 0.1, 0.33, 0.5, 0.77, 0.999, 1.0] { + let byte = alpha_byte(requested); + let applied = f64::from(byte) / 255.0; + assert!( + applied >= WINDOW_ALPHA_MIN - 1e-12, + "requested={requested} → byte={byte} → 实际 α={applied},低于下限 {WINDOW_ALPHA_MIN}" + ); + assert!( + applied <= 1.0, + "requested={requested} → 实际 α={applied} 超过 1.0" + ); + } +} + +#[test] +fn missing_or_opaque_alpha_never_touches_the_window() { + for none in [None, Some(0.0), Some(-0.5), Some(f64::NAN)] { + assert_eq!( + apply_window_alpha(1234, none), + AlphaOutcome::Rejected(AlphaRejection::NoAlpha), + "{none:?} 应被拒绝(整窗穿透不许发生)" + ); + } + // α = 1.0:两个平台都返回 Opaque —— 正好证明「不调 FFI」不是靠 Unsupported 混过去的。 + assert_eq!(apply_window_alpha(1234, Some(1.0)), AlphaOutcome::Opaque); + // 合法 α 但没有句柄:拒绝,绝不下发。 + assert_eq!( + apply_window_alpha(0, Some(0.5)), + AlphaOutcome::Rejected(AlphaRejection::NoWindow) + ); +} + +#[cfg(target_os = "windows")] +#[test] +fn invalid_handle_reports_a_win32_failure_instead_of_panicking() { + // 用 1 号句柄(永远无效)真调一次 API:验的是**返回值语义**,不是观感。 + match apply_window_alpha(1, Some(0.5)) { + AlphaOutcome::Failed { code } => { + assert_eq!(code, 1400, "ERROR_INVALID_WINDOW_HANDLE") + } + other => panic!("无效句柄应报 Failed,实际 {other:?}"), + } +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn non_windows_stub_reports_unsupported() { + assert_eq!(apply_window_alpha(1, Some(0.5)), AlphaOutcome::Unsupported); +} + +/// **受控对照实验**(M116):同进程造三种窗口样式,逐行读 DWM 属性。 +/// +/// 为什么是 `#[ignore]`:它要真造窗口(CI 的无桌面环境里跑不出读数),所以只在需要出证据时 +/// 手动跑: +/// +/// ```text +/// cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture +/// ``` +/// +/// 输出就是报告里那张表(`| style | requested | exstyle | readback | … |`)。 +/// **抓不住**:角上到底是透明还是方块(那要合成输出像素,本机 RDP 断连拿不到)。 +#[cfg(target_os = "windows")] +#[test] +#[ignore = "要真窗口:手动跑 cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture"] +fn m116_corner_controlled_experiment() { + println!("{}", super::probe::TABLE_HEADER); + for row in super::controlled_experiment() { + println!("{}", row.describe()); + } +} + +/// **M116 实测踩到的判据错误**:`DwmGetWindowAttribute` 在本机(RDP 断连)回读偏好返回 +/// `0x7FF8FFA9`——**正数**(严重性位为 0 = 成功),但 `hr == 0` 的写法会把它读成失败, +/// 于是「属性到底设上没有」变成猜谜。 +/// +/// 抓得住:把成功判据写回 `hr == 0`(那会让本机永远报「读不回来」)。 +/// 抓不住:DWM 具体返回哪个信息码(那是运行期读数)。 +#[test] +fn hresult_success_follows_the_severity_bit_not_equality_with_zero() { + use super::hresult_ok; + assert!(hresult_ok(0), "S_OK"); + assert!(hresult_ok(1), "S_FALSE 也是成功"); + // 失败码:严重性位为 1(i32 负数)。 + assert!(!hresult_ok(0x8007_0057_u32 as i32), "E_INVALIDARG"); + assert!(!hresult_ok(-1)); + // **打印规则**:失败码必须印成 `hr as u32` 的原始位。 + // `unsigned_abs()` 会把 `0x8007_0057` 印成 `0x7FF8_FFA9`(M116 实测踩过), + // 于是「E_INVALIDARG」读起来像「一个信息级成功码」,整个证据链就毁了。 + let e_invalidarg = 0x8007_0057_u32 as i32; + assert_eq!(e_invalidarg as u32, 0x8007_0057); + assert_ne!(e_invalidarg.unsigned_abs(), 0x8007_0057); +} + +/// **两种「错误号」不同源,打印方式也不同**(tower 裁定 2026-11-03:不许统一)。 +/// +/// - `GetLastError()` 是 **DWORD**:`87`(`ERROR_INVALID_PARAMETER`)/ `1400` +/// (`ERROR_INVALID_WINDOW_HANDLE`)这类小整数,没有严重性位,`set_layered` 用 +/// `raw_os_error().unsigned_abs()` 取的就是它; +/// - `Dwm*WindowAttribute` 返回 **HRESULT**:成功看 bit 31,打印用 `as u32` 原始位。 +/// +/// 抓得住:有人把 `set_layered` 也改成 `as u32`(或把 DWM 那侧改成 `unsigned_abs`)—— +/// 前者对 `87` 无害但对高位被置的错误号会印出负数,后者会把 `E_INVALIDARG` 印成 +/// `0x7FF8FFA9`(M116 实测踩过的那个坑)。 +#[test] +fn dword_error_codes_and_hresults_are_printed_differently() { + use super::hresult_ok; + + // DWORD 侧:典型值是小的正数,两种打印结果一致(所以错误只会在高位情形暴露)。 + for dword in [87_i32, 1400] { + assert_eq!(dword.unsigned_abs(), dword as u32); + assert!( + hresult_ok(dword), + "DWORD 不是 HRESULT,不该拿这个判据去读它" + ); + } + + // HRESULT 侧:失败码的原始位与 `unsigned_abs()` 必须不同。 + let e_invalidarg = 0x8007_0057_u32 as i32; + assert!(!hresult_ok(e_invalidarg)); + assert_eq!(e_invalidarg as u32, 0x8007_0057); + assert_ne!(e_invalidarg.unsigned_abs(), e_invalidarg as u32); +} + +/// **产品日志行 `[corner] …` 的真实取值**(同一套判据 + 同一套格式化,句柄换成探针窗口)。 +/// +/// `excluded_reason=` / `frame=` / `layered=` 三栏是「为什么我们的窗口没有圆角」的唯一现场线索; +/// 报告里要贴真实取值,不是模板。跑法同 `m116`: +/// +/// ```text +/// cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture +/// ``` +#[cfg(target_os = "windows")] +#[test] +#[ignore = "要真窗口:手动跑 cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture"] +fn m116_corner_log_line_on_real_windows() { + use crate::platform::win32::corner::plan_corner; + use unlucky_presenter::prefs::WindowCorner; + + let os = crate::platform::current_windows_version(); + let plan = plan_corner(WindowCorner::System, os, crate::Backend::Gpu); + for (label, style, ex_style) in [ + ("decorated", super::probe::WS_OVERLAPPEDWINDOW, 0u32), + ("popup", super::probe::WS_POPUP, 0u32), + ("popup+layered", super::probe::WS_POPUP, 0x0008_0000), + ] { + let hwnd = super::create_probe_window(style, ex_style); + if hwnd == 0 { + println!("[corner] {label} window=none"); + continue; + } + super::show_probe_window(hwnd); + println!( + "[corner] {label} {} {} {}", + plan.log_line(WindowCorner::System), + super::apply_corner(hwnd, &plan).describe(), + super::dwm::style_summary(hwnd), + ); + super::destroy_probe_window(hwnd); + } + + // 设置里选「直角」时的取值(`excluded_reason` 必须变成 `user`)。 + let square = plan_corner(WindowCorner::Square, os, crate::Backend::Gpu); + println!( + "[corner] policy=square {}", + square.log_line(WindowCorner::Square) + ); +} 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..8a57c25 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -52,9 +52,16 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"]; /// `1.4.0` 登记 P4 的外观下层三键(`appearance.window_opacity` / `titlebar_mode` / /// `palette_source`,M91 · D36/D37/D38):仍然只推 minor、不加迁移步骤——三者的出厂值 /// (不透明 / auto / 内置色卡)正是「老文件没有这些键」时的等价物。 +/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`): +/// 同一套纯增量语义——老文件缺该键 ⇒ 读默认 `system` ⇒ 跟随系统,与写进文件后行为一致。 +/// **向前兼容(旧版本读新文件)**:本仓的 [`super::catalog`] 是白名单校验, +/// 但读路径 (`SettingDocument::load`) 只**登记**陌生键、不报错也不删除(`RenderMode::Full` +/// 会把它们原样写回),所以旧版本打开新文件不会崩,只是看不到「直角」这一档; +/// 只有**显式 `store.set("appearance.window_corner", …)`** 在旧版本上才会 +/// 返回 `SettingError::UnknownKey`(新版本自己不会这么调)。 #[must_use] pub fn launcher_version() -> SchemaVersion { - SchemaVersion::new(1, 4, 0) + SchemaVersion::new(1, 5, 0) } /// `config/launcher.json` 的 schema。 @@ -336,6 +343,16 @@ pub fn launcher_schema() -> SettingSchema { AiPolicy::audited(), "配色来源档(D38 三级):background-image(背景图取色)/ wallpaper(桌面壁纸取色)/ preset(内置色卡,默认)", )) + // M116:窗口外框圆角策略。只有两档(system / square)——系统不暴露圆角半径, + // 「固定 8/12/16」只能是自绘圆角,而自绘圆角在无边框 + 整窗 α 上要裁内容层, + // 会连标题栏三键与缩放热区一起裁掉(用户定案 2026-11-03)。 + .with(SettingSpec::enumeration( + "appearance.window_corner", + "system", + super::appearance::WINDOW_CORNERS, + AiPolicy::audited(), + "窗口圆角:system(跟随系统:Win11/Server 2025 走 DWM,Win10 保持直角)/ square(总是直角),默认 system", + )) // 已废弃:D17 定案 appearance.theme_mode 为准(旧值读兼容映射,写永远走新键)。 .with( SettingSpec::new( 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..edd118d 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -57,6 +57,10 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("FontCjkReady", "Chinese font ready: {family}", "中文字体已就绪:{family}"), ("SettingsLanguage", "Language", "界面语言"), ("SettingsLanguageHint", "Switches the interface language. Chinese text needs a Chinese font.", "切换界面语言。显示中文需要中文字体。"), + ("SettingsAppearanceCorner", "Window corners", "窗口圆角"), + ("SettingsAppearanceCornerSystem", "Follow system", "跟随系统"), + ("SettingsAppearanceCornerSquare", "Square", "直角"), + ("SettingsAppearanceCornerHint", "Windows 11 and Windows Server 2025 round the window through the compositor, so the edges stay smooth. Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply, and we do not draw our own corners.", "Windows 11 与 Windows Server 2025 由系统合成器画圆角,边缘平滑;Windows 10 保持直角;macOS 与 Linux 交给桌面自己的规则,我们不自己画圆角。"), ("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"), ("DownloadTitle", "Version management", "版本管理"), ("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"), diff --git a/crates/i18n/src/catalog/settings/appearance.rs b/crates/i18n/src/catalog/settings/appearance.rs index d32449e..0dfb1c8 100644 --- a/crates/i18n/src/catalog/settings/appearance.rs +++ b/crates/i18n/src/catalog/settings/appearance.rs @@ -1,11 +1,30 @@ -//! 设置页「Appearance」分组文案:**预留**(真控件在 `pages/appearance/section.rs`,文案还没接 i18n)。 +//! 设置页「Appearance」分组文案(M116 首次落地:窗口圆角一档)。 //! //! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。 -//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。 +//! +//! ## 为什么只有圆角这一组的 key +//! +//! 本分组的既有控件(整窗/面板透明度、背景图、配色来源……)是 M93 落的,文案是英文基线字面量 +//! (`pages/appearance/section.rs`)。M116 新增的圆角档位**从一开始就走词表**(tower 口径: +//! 设置项文案不许硬编码),所以这里先只有它这一组;老控件接词表归 i18n 接线 mission。 +//! +//! ## 文案口径(不许暗示所有平台一致) +//! +//! 用户定案:Win11 / Server 2025 开系统圆角,Win10 可以不开,其他系统按窗口合成器的默认规则。 +//! 所以说明句必须写清「谁负责画圆」而不是「我们支持圆角」——后半句在 Win10 上是假的。 macro_rules! settings_appearance_entries { ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { - $next! { $($rest,)* { $($acc)* } } + $next! { $($rest,)* { + $($acc)* + SettingsAppearanceCorner => ("Window corners", "窗口圆角"), + SettingsAppearanceCornerSystem => ("Follow system", "跟随系统"), + SettingsAppearanceCornerSquare => ("Square", "直角"), + SettingsAppearanceCornerHint => ( + "Windows 11 and Windows Server 2025 round the window through the compositor, so the edges stay smooth. Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply, and we do not draw our own corners.", + "Windows 11 与 Windows Server 2025 由系统合成器画圆角,边缘平滑;Windows 10 保持直角;macOS 与 Linux 交给桌面自己的规则,我们不自己画圆角。" + ), + } } }; } diff --git a/crates/presenter/src/prefs.rs b/crates/presenter/src/prefs.rs index d2224ad..d2d2342 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; @@ -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); +} From 25d860b2b348a5b8fa2804f16cec5c67cc78f9a1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 23:10:08 +0800 Subject: [PATCH 2/9] =?UTF-8?q?refactor(app-iced):=20M116=20=E6=8A=8A?= =?UTF-8?q?=E5=9C=86=E8=A7=92=E5=8D=95=E6=B5=8B=E6=90=AC=E5=87=BA=20corner?= =?UTF-8?q?.rs=E3=80=81=E6=97=A5=E5=BF=97=E8=A1=8C=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=88=B0=20format=5Fline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `platform/win32/corner.rs` 从 413 行降到 200 行(AGENTS.md §3-1 的 400 行硬线): 单测搬到 `corner/tests.rs`。 - 日志行的**形状**收敛成一个纯函数 `corner::format_line(context, plan, policy, outcome, style)`, 于是「`excluded_reason` 这一栏还在不在」不依赖真窗口也能被断言。 新增 `the_log_line_shape_is_pinned_with_the_measured_readings`:用本机探针窗口的真实读数 (Server 2025 / 26100、读回 E_INVALIDARG、frame=popup)逐字段钉死, 并把 policy × 系统 × 渲染档四种 `excluded_reason`(none / user / os-build / cpu-tier / unknown-os)全覆盖。 - `set_layered` 补一段「这里是 GetLastError 的 DWORD 语义,`unsigned_abs()` 有意保留; DWM 那侧是 HRESULT 语义、必须 `as u32`」的注释 + 对应用例(tower 2026-11-03 裁定:两者不同源,不许统一)。 门禁:fmt --check ✓ / clippy -D warnings(5 个改动 crate)✓ / lib 测试 core 266 + design 163 + i18n 24 + presenter 129 + app-iced 418 全绿。 --- crates/app-iced/src/platform/win32/corner.rs | 216 +---------------- .../src/platform/win32/corner/tests.rs | 218 ++++++++++++++++++ 2 files changed, 219 insertions(+), 215 deletions(-) create mode 100644 crates/app-iced/src/platform/win32/corner/tests.rs diff --git a/crates/app-iced/src/platform/win32/corner.rs b/crates/app-iced/src/platform/win32/corner.rs index 4b242b5..8e6620a 100644 --- a/crates/app-iced/src/platform/win32/corner.rs +++ b/crates/app-iced/src/platform/win32/corner.rs @@ -197,218 +197,4 @@ pub fn format_line( } #[cfg(test)] -mod corner_tests { - use super::{plan_corner, CornerExclusion, CornerPlan, CornerSource}; - use crate::Backend; - use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD}; - - /// 本机实测版本(Server 2025 / build 26100)。 - fn win11() -> WindowsVersion { - WindowsVersion::new(10, 0, 26_100) - } - - /// Win10 22H2(build 19045):DWM 没有圆角偏好属性。 - fn win10() -> WindowsVersion { - WindowsVersion::new(10, 0, 19_045) - } - - /// **抓得住**:判定链的顺序与每一格的原因码(把 `policy`/`os`/`backend` 任一条判反都会红)。 - /// **抓不住**:DWM 到底有没有真画圆角(那要像素,见 `platform/win32` 的探针输出)。 - #[test] - fn truth_table_policy_os_backend() { - let cases = [ - // policy=Square:任何系统/后端都直角,原因是用户选的。 - ( - WindowCorner::Square, - Some(win11()), - Backend::Gpu, - CornerSource::Square, - Some(CornerExclusion::UserSquare), - ), - ( - WindowCorner::Square, - Some(win10()), - Backend::Cpu, - CornerSource::Square, - Some(CornerExclusion::UserSquare), - ), - ( - WindowCorner::Square, - None, - Backend::Auto, - CornerSource::Square, - Some(CornerExclusion::UserSquare), - ), - // policy=System + 探测不出系统:不许猜「Win11」。 - ( - WindowCorner::System, - None, - Backend::Gpu, - CornerSource::Square, - Some(CornerExclusion::UnknownOs), - ), - // Win10:直角,GPU 档原因是版本、CPU 档原因是渲染档(同一个直角,原因不同)。 - ( - WindowCorner::System, - Some(win10()), - Backend::Gpu, - CornerSource::Square, - Some(CornerExclusion::OsBuild), - ), - ( - WindowCorner::System, - Some(win10()), - Backend::Auto, - CornerSource::Square, - Some(CornerExclusion::OsBuild), - ), - ( - WindowCorner::System, - Some(win10()), - Backend::Cpu, - CornerSource::Square, - Some(CornerExclusion::CpuTier), - ), - // Win11 / Server 2025:三个后端都用 DWM(D36 只管我们自己的合成技巧)。 - ( - WindowCorner::System, - Some(win11()), - Backend::Gpu, - CornerSource::Dwm, - None, - ), - ( - WindowCorner::System, - Some(win11()), - Backend::Cpu, - CornerSource::Dwm, - None, - ), - ( - WindowCorner::System, - Some(win11()), - Backend::Auto, - CornerSource::Dwm, - None, - ), - ]; - - for (policy, os, backend, want_source, want_excluded) in cases { - let plan = plan_corner(policy, os, backend); - assert_eq!( - plan.source, want_source, - "policy={policy:?} os={os:?} backend={backend:?}" - ); - assert_eq!( - plan.excluded, want_excluded, - "policy={policy:?} os={os:?} backend={backend:?} 的原因码" - ); - } - } - - /// 边界:`WIN11_MIN_BUILD` **本身**算 Win11(`>=`,不是 `>`)。 - /// - /// 抓得住:把 `build < WIN11_MIN_BUILD` 写成 `<=`——Win11 21H2(build 22000)会静默掉成直角, - /// 而那正好是第一台 Win11 机器。 - #[test] - fn the_win11_boundary_is_inclusive() { - let at = WindowsVersion::new(10, 0, WIN11_MIN_BUILD); - assert_eq!( - plan_corner(WindowCorner::System, Some(at), Backend::Gpu).source, - CornerSource::Dwm - ); - let below = WindowsVersion::new(10, 0, WIN11_MIN_BUILD - 1); - assert_eq!( - plan_corner(WindowCorner::System, Some(below), Backend::Gpu).source, - CornerSource::Square, - "build 21999 = Win10 22H2 家族末尾,不能算 Win11" - ); - } - - /// 日志行的形状:四个字段都在,且 `excluded_reason=none` 与 `reason=user` 不许混。 - /// - /// 为什么要钉这一条:这行是「后来的人分辨没生效 / 生效了看不见」的唯一依据(任务书原话), - /// 少一栏就等于把这条线索丢掉。 - #[test] - fn the_log_line_carries_every_field() { - let dwm = plan_corner(WindowCorner::System, Some(win11()), Backend::Gpu) - .log_line(WindowCorner::System); - assert!(dwm.starts_with("corner=policy=system"), "{dwm}"); - assert!(dwm.contains("source=dwm"), "{dwm}"); - assert!(dwm.contains("os=10.0.26100"), "{dwm}"); - assert!(dwm.contains("excluded_reason=none"), "{dwm}"); - assert!(dwm.contains("radius=system"), "{dwm}"); - - let square = plan_corner(WindowCorner::Square, Some(win11()), Backend::Gpu) - .log_line(WindowCorner::Square); - assert!(square.contains("source=square"), "{square}"); - assert!(square.contains("excluded_reason=user"), "{square}"); - - let unknown: CornerPlan = plan_corner(WindowCorner::System, None, Backend::Gpu); - assert!(unknown - .log_line(WindowCorner::System) - .contains("os=unknown")); - } -} - -/// **日志行的形状**,用 M116 探针窗口的**真实读数**构造(`DevDocs/artifacts/m116/probe-table.md`)。 -/// -/// 抓得住:字段被删/改名/顺序错乱(`excluded_reason` 少一栏、`radius=system` 丢了、 -/// `frame` / `layered` 被挪出这一行)——这些都会让「没生效 vs 生效了看不见」的区分重新变成猜谜。 -/// 抓不住:产品进程里那一行到底长什么样(本 mission 没构建 bin;合并态由 tower 编 `--bins` 补)。 -#[test] -fn the_log_line_shape_is_pinned_with_the_measured_readings() { - use super::{format_line, plan_corner, CornerPlan}; - use crate::Backend; - use unlucky_presenter::prefs::WindowsVersion; - - // 本机实测:Server 2025 / build 26100;读回 `E_INVALIDARG`;popup 窗口 layered=on。 - let os = WindowsVersion::new(10, 0, 26_100); - let plan: CornerPlan = plan_corner(WindowCorner::System, Some(os), Backend::Gpu); - let line = format_line( - "boot", - &plan, - WindowCorner::System, - "pref=2 readback=? read_hr=0x80070057", - "frame=popup layered=on", - ); - assert_eq!( - line, - "[corner] boot corner=policy=system source=dwm os=10.0.26100 excluded_reason=none radius=system pref=2 readback=? read_hr=0x80070057 frame=popup layered=on" - ); - - // 设置里选「直角」:原因码必须从 `none` 变成 `user`,其余字段不动。 - let square = format_line( - "change", - &plan_corner(WindowCorner::Square, Some(os), Backend::Gpu), - WindowCorner::Square, - "pref=1 readback=? read_hr=0x80070057", - "frame=popup layered=off", - ); - assert!( - square.contains("corner=policy=square source=square"), - "{square}" - ); - assert!(square.contains("excluded_reason=user"), "{square}"); - assert!(square.contains("frame=popup layered=off"), "{square}"); - - // Win10:原因码是 `os-build`(GPU 档)/ `cpu-tier`(CPU 档),来源仍是 square。 - let win10 = WindowsVersion::new(10, 0, 19_045); - let os_build = - plan_corner(WindowCorner::System, Some(win10), Backend::Gpu).log_line(WindowCorner::System); - assert!( - os_build.contains("source=square") && os_build.contains("excluded_reason=os-build"), - "{os_build}" - ); - let cpu = - plan_corner(WindowCorner::System, Some(win10), Backend::Cpu).log_line(WindowCorner::System); - assert!(cpu.contains("excluded_reason=cpu-tier"), "{cpu}"); - - // 探测不到版本(macOS / X11 / Wayland 落到同一格):`unknown-os`,不许猜 Win11。 - let unknown = - plan_corner(WindowCorner::System, None, Backend::Gpu).log_line(WindowCorner::System); - assert!( - unknown.contains("os=unknown") && unknown.contains("excluded_reason=unknown-os"), - "{unknown}" - ); -} +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..6cf5eee --- /dev/null +++ b/crates/app-iced/src/platform/win32/corner/tests.rs @@ -0,0 +1,218 @@ +//! `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 生效了看不见」的区分重新变成猜谜。 +/// 抓不住:产品进程里那一行到底长什么样(本 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。 + let os = WindowsVersion::new(10, 0, 26_100); + let plan: CornerPlan = plan_corner(WindowCorner::System, Some(os), Backend::Gpu); + let line = format_line( + "boot", + &plan, + WindowCorner::System, + "pref=2 readback=? read_hr=0x80070057", + "frame=popup layered=on", + ); + assert_eq!( + line, + 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" + ) + ); + + // 设置里选「直角」:原因码必须从 `none` 变成 `user`,其余字段不动。 + let square = format_line( + "change", + &plan_corner(WindowCorner::Square, Some(os), Backend::Gpu), + WindowCorner::Square, + "pref=1 readback=? read_hr=0x80070057", + "frame=popup layered=off", + ); + assert!( + square.contains("corner=policy=square source=square"), + "{square}" + ); + assert!(square.contains("excluded_reason=user"), "{square}"); + assert!(square.contains("frame=popup layered=off"), "{square}"); + + // Win10:原因码是 `os-build`(GPU 档)/ `cpu-tier`(CPU 档),来源仍是 square。 + let win10 = WindowsVersion::new(10, 0, 19_045); + let os_build = + plan_corner(WindowCorner::System, Some(win10), Backend::Gpu).log_line(WindowCorner::System); + assert!( + os_build.contains("source=square") && os_build.contains("excluded_reason=os-build"), + "{os_build}" + ); + let cpu = + plan_corner(WindowCorner::System, Some(win10), Backend::Cpu).log_line(WindowCorner::System); + assert!(cpu.contains("excluded_reason=cpu-tier"), "{cpu}"); + + // 探测不到版本(macOS / X11 / Wayland 落到同一格):`unknown-os`,不许猜 Win11。 + let unknown = + plan_corner(WindowCorner::System, None, Backend::Gpu).log_line(WindowCorner::System); + assert!( + unknown.contains("os=unknown") && unknown.contains("excluded_reason=unknown-os"), + "{unknown}" + ); +} From 41746d1612c032eb642f52f863fa45aa487fcac6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 23:59:46 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(app-iced):=20M116=20r1=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=20=E2=80=94=E2=80=94=20=E5=8F=A3=E5=BE=84=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E4=B8=BA=E3=80=8C=E4=B8=8D=E6=89=BF=E8=AF=BA=E3=80=8D?= =?UTF-8?q?=E3=80=81=E4=BF=AE=E6=8E=89=E4=BC=AA=E9=80=A0=E8=AF=BB=E6=95=B0?= =?UTF-8?q?=E4=B8=8E=E4=B8=A4=E5=A4=84=E9=94=99=E6=B3=A8=E9=87=8A=E3=80=81?= =?UTF-8?q?=E5=9C=86=E8=A7=92=E4=B8=8B=E5=8F=91=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四条 P1(全是「口径与证据不符」,行为未变): 1. 把「Win11 一定有系统圆角、边缘平滑」从事实降级:`corner.rs` 的平台矩阵改成 「实测 / 解释 / 推断 / 未验」四列,并明写本机只测到「写入被接受 / 回读 E_INVALIDARG / 无边框窗口没有 DWM 调整带」;设置页与 golden 的说明句改成「我们**请求**系统圆角, 是否圆、边缘什么样由合成器决定」,不再承诺结果。 2. 删掉证据目录里那条伪造读数 `readback=2`:本机三种样式回读全是 `E_INVALIDARG`, 该值物理上产不出来,也不可能由 `format_line` 产出。改成显式的「合成示例(非实测)」, 并把每一栏标注成真实/示例来源。probe-table 的「没有一条是推断」标题一并拆开, 结论 4/5 明确标为**解释**(并写明 Δframe 之差是样式位的同义反复,不能反推 DWM 决策)。 3. `keys.rs` 的「旧读新」语义按实际链路改写:1.4.0 读 1.5.0 文件走 `SchemaCompat::check_url` → `ReadOnlyPreserveSchema` → `SchemaCompat::access` → `FileAccess::ReadOnly`,**整份配置只读**(值照读、`store.set` 报 `SettingError::ReadOnly`、 `save` 跳过),不是「只是少一档」。 4. 两处错注释订正: - `hresult_ok` 的理由改成 `S_FALSE` 的通用语义,并明写 `0x7FF8FFA9` **不是** DWM 的返回值 (本机实测是 `E_INVALIDARG 0x80070057`),那是我早期用 `unsigned_abs()` 打印 HRESULT 的产物; 对应单测也把该常量标为「合成值,只用来钉规则」。 - `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS` 的文档改成「本机三样式三态实测全 1,不能当判据, 仅作诊断读数」。 两条裁定: - CPU 档在 Win11 仍走 DWM(tower 裁定保留):理由写进 `plan_corner` 文档——D36 约束的是我们自己的 渲染路径,DWM 圆角是合成器侧的,不增加我方负担;设置页说明同步加了「软件渲染也一样」。 - 「圆角窗口上拖边缩放仍命中」实测未做(RDP 断开、无指针注入手段):mission 里以 note 记为 dropped (store 的 task_drop 对已完成任务不生效,工具层面无法取消勾选,已在 note 里写清)。 六条 P2: 1. probe-table 拆「实测 / 解释 / 未验」并删掉绝对化标题。 2. CPU 档裁定见上(任务书由 tower 侧调整)。 3. 拖边缩放任务记账更正见上。 4. **圆角下发去重**:新增 `corner::{CornerApplied, needs_redispatch}`(指纹 = 策略 / 来源 / 原因码 / 有没有句柄,不含只有真下发才拿得到的读数),`App::apply_window_corner` 改成 `&mut self` 并在判定未变时**直接返回**(不动窗口、不打日志);`App` 新增 `corner_applied` 字段。 新增测试 `the_same_policy_is_dispatched_only_once`:连续 20 次同策略只下发/记 1 次, 换档与「首次拿到句柄」必须重下。 5. `dwm_get_rect` 的 `hr == 0` 改成 `hresult_ok(hr)`,「判据只有一处」名副其实。 6. 报告口径修正(诊断已产出、UI 接线待做——`SettingsBridge` 未暴露 doc,接线要改 presenter 的 `settings_bridge.rs`,不在本 mission scope),本轮不动代码。 门禁:fmt --check exit 0;clippy(5 个 crate)--all-targets -D warnings 零 warning; lib 测试 app-iced 419(+1 去重测试)/ core 266 / design 163 / i18n 24 / presenter 129 全绿; 改动文件仍全部 ≤400 行(最大 keys.rs 398)。 --- crates/app-iced/src/app/state.rs | 6 ++ crates/app-iced/src/app/update/appearance.rs | 19 +++- crates/app-iced/src/platform/win32/corner.rs | 88 +++++++++++++++---- .../src/platform/win32/corner/tests.rs | 48 ++++++++++ .../src/platform/win32/transparency.rs | 13 ++- .../src/platform/win32/transparency/dwm.rs | 8 +- .../src/platform/win32/transparency/tests.rs | 21 +++-- crates/core/src/setting/keys.rs | 15 ++-- crates/i18n/src/catalog/golden.rs | 2 +- .../i18n/src/catalog/settings/appearance.rs | 4 +- 10 files changed, 184 insertions(+), 40 deletions(-) diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 8de1625..c3bce41 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 { @@ -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/appearance.rs b/crates/app-iced/src/app/update/appearance.rs index ec91671..f06a805 100644 --- a/crates/app-iced/src/app/update/appearance.rs +++ b/crates/app-iced/src/app/update/appearance.rs @@ -23,6 +23,7 @@ //! (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; @@ -30,14 +31,28 @@ use super::super::state::App; impl App { /// 把当前圆角策略下发到窗口,并打一行可分辨的日志。 /// + /// ## 去重(M116 r1 复审 P2-4) + /// + /// 设置页的滑条(整窗 α / 面板 α / 暗化)每动一下都会走一次外观变更,而圆角与那些键无关。 + /// 判定完全没变时这里**直接返回**:不动窗口、不打日志。判据是 + /// [`corner::needs_redispatch`](纯函数,有「同一策略 N 次只记 1 行」的测试), + /// 指纹只含「策略 / 来源 / 原因码 / 有没有句柄」,不含只有真下发才拿得到的读数。 + /// /// 没有句柄时**也打日志**(`window=none`):不能让「没生效」只体现为屏幕上没变化。 - pub(crate) fn apply_window_corner(&self, context: &str) { + /// 句柄从无到有会让指纹变化 ⇒ 首次拿到 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, ); + let next = CornerApplied::new(policy, &plan, self.hwnd.is_some()); + 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=` 必须和圆角结论在同一行——否则以后只能猜是哪一条。 @@ -50,7 +65,7 @@ impl App { }; eprintln!( "{}", - platform::win32::corner::format_line(context, &plan, policy, &outcome, &style) + corner::format_line(context, &plan, policy, &outcome, &style) ); } } diff --git a/crates/app-iced/src/platform/win32/corner.rs b/crates/app-iced/src/platform/win32/corner.rs index 8e6620a..2c641a7 100644 --- a/crates/app-iced/src/platform/win32/corner.rs +++ b/crates/app-iced/src/platform/win32/corner.rs @@ -9,14 +9,21 @@ //! //! 真正下发给窗口的动作在 [`super::transparency`](全仓唯一 `unsafe` 落点)。 //! -//! ## 平台矩阵(真值表的出处) +//! ## 平台矩阵(哪些是实测、哪些是推断) //! -//! | 平台 | 系统圆角 | 我们做什么 | +//! | 平台 | 我们做什么 | 依据级别 | //! |---|---|---| -//! | Win11 / Server 2025(build ≥ 22000) | 有(DWM 合成器画,抗锯齿) | `DWMWCP_ROUND` | -//! | Win10 及更早(build < 22000) | 无(该属性不存在) | 直角;不做 `SetWindowRgn` 毛边默认 | -//! | macOS | 有(`layer.cornerRadius`,系统/合成器决定) | **不主动加**,交给系统 | -//! | Linux X11 / Wayland | 看合成器(客户端管不了外框) | **不承诺**,直角即如实结果 | +//! | 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 调整带(`EXTENDED_FRAME_BOUNDS − GetWindowRect` = `+0,+0`,有边框是 `+0,−7`)。 +//! 「圆角长什么样」归真机验收,证据目录见 `DevDocs/artifacts/m116/`。 //! //! 后两行在本 crate 里落到同一格:探测不到 Windows 版本 ⇒ `UnknownOs` ⇒ 直角。 //! 这是「不承诺」,不是「做不到」——两者的区别写在日志的原因码里。 @@ -29,10 +36,10 @@ use crate::Backend; /// /// 只有两种来源,且**互相排斥**: /// -/// | 来源 | 谁画圆角 | 抗锯齿 | 我们做什么 | -/// |---|---|---|---| -/// | [`CornerSource::Dwm`] | DWM 合成器 | 有(系统画) | 只下发 `DWMWA_WINDOW_CORNER_PREFERENCE` | -/// | [`CornerSource::Square`] | 没人 | — | 明确下发 `DWMWCP_DONOTROUND`,把「方」也变成可观测的选择 | +/// | 来源 | 谁画圆角 | 我们做什么 | +/// |---|---|---| +/// | [`CornerSource::Dwm`] | 系统合成器(是否真画、半径多少都不由我们定,也读不到) | 只下发 `DWMWA_WINDOW_CORNER_PREFERENCE` | +/// | [`CornerSource::Square`] | 没人 | 明确下发 `DWMWCP_DONOTROUND`,把「方」也变成可观测的选择 | /// /// **没有 `Self`(自绘圆角)这一档**,这是刻意的(用户定案 2026-11-03 + tower 裁定): /// 窗口是 `decorations=false` + 自绘标题栏 + 整窗 `LWA_ALPHA`,自绘圆角意味着把内容层裁成圆角, @@ -136,11 +143,14 @@ impl CornerPlan { /// | `System` | `< 22000` | CPU | `Square` | `CpuTier` | /// | `System` | `>= 22000` | 任意 | `Dwm` | `None` | /// -/// 为什么 CPU 档在 Win11 上**照样**用 DWM 圆角:D36 限制的是**我们**做的合成技巧(面板 α / -/// 玻璃 / 模糊),而 DWM 圆角是系统合成器自己的行为,不消耗我们的渲染预算,也不违反 -/// 「CPU 档不碰透明叠加」——把 Win11 的圆角在 CPU 档砍掉等于凭空造一个没人要的降级。 -/// 反过来在非 DWM 平台上,圆角只剩「自绘裁切 / `SetWindowRgn` 毛边」这两条路,那才是 D36 -/// 要拦的东西,所以那一格落 `cpu-tier` 而不是 `os-build`(同一个直角,**原因不同**)。 +/// 为什么 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, @@ -196,5 +206,53 @@ pub fn format_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, +} + +impl CornerApplied { + /// 由判定与「有没有句柄」构造指纹。 + #[must_use] + pub const fn new(policy: WindowCorner, plan: &CornerPlan, has_window: bool) -> Self { + Self { + policy, + source: plan.source, + excluded: plan.excluded, + has_window, + } + } +} + +/// 这一次要不要真的下发 / 记日志(纯函数)。 +/// +/// `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 index 6cf5eee..e60aadd 100644 --- a/crates/app-iced/src/platform/win32/corner/tests.rs +++ b/crates/app-iced/src/platform/win32/corner/tests.rs @@ -216,3 +216,51 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() { "{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); + 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) + )); + + // 句柄从无到有必须重下(首次拿到 HWND 时才真能设属性)。 + let mut no_window: Option = None; + let first = CornerApplied::new(WindowCorner::System, &plan, 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) + )); +} diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 82eac7f..90c6809 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -240,9 +240,13 @@ pub(super) fn dwm_set_u32(hwnd: isize, attribute: u32, value: u32) -> i32 { /// `HRESULT` 的成功判据:**看严重性位**(bit 31),不是「等于 0」。 /// -/// Win32 里成功码不只有 `S_OK`(0):`S_FALSE`(1) 与各种 `S_*` / 信息级码都是成功。 -/// M116 实测:本机 DWM 回读偏好返回 `0x7FF8FFA9`(正数),`hr == 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 @@ -287,7 +291,8 @@ pub(super) fn dwm_get_rect(hwnd: isize, attribute: u32) -> Option { u32::try_from(std::mem::size_of::()).unwrap_or(16), ) }; - (hr == 0).then_some(rect) + // 与 `dwm_get_u32_hr` 同一条判据:`HRESULT` 看严重性位,不是「等于 0」(judgment 只留一处)。 + hresult_ok(hr).then_some(rect) } /// `GetWindowRect`:窗口矩形(屏幕坐标)。失败返回 `None`。 diff --git a/crates/app-iced/src/platform/win32/transparency/dwm.rs b/crates/app-iced/src/platform/win32/transparency/dwm.rs index 1b956c4..330d2c8 100644 --- a/crates/app-iced/src/platform/win32/transparency/dwm.rs +++ b/crates/app-iced/src/platform/win32/transparency/dwm.rs @@ -26,8 +26,10 @@ pub const DWMWCP_DONOTROUND: u32 = 1; pub const DWMWCP_ROUND: u32 = 2; /// `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS`:可见边框厚度(像素,Win11 起)。 /// -/// 这是本机**唯一**能拿到的「DWM 到底把这个窗口当不当有框窗口」的数值信号: -/// 圆角窗口带有一条 1px(按 DPI 缩放)的可见边框,直角窗口为 0。 +/// ⚠️ **本机实测它不能当「圆不圆」的判据**(我最初以为能,被探针否掉了): +/// 三种窗口样式 × 三种状态(默认 / `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; @@ -161,7 +163,7 @@ pub fn corner_preference_hr(hwnd: isize) -> (Option, u32) { } } -/// 读可见边框厚度(像素)。这是本机唯一能拿到的「DWM 把窗口当不当有框窗口」的数值信号。 +/// 读可见边框厚度(像素)。**诊断用,不可作判据**(本机三样式三态实测全是 1,见常量文档)。 #[must_use] pub fn visible_border_thickness(hwnd: isize) -> Option { #[cfg(target_os = "windows")] diff --git a/crates/app-iced/src/platform/win32/transparency/tests.rs b/crates/app-iced/src/platform/win32/transparency/tests.rs index f631de6..dbd6c25 100644 --- a/crates/app-iced/src/platform/win32/transparency/tests.rs +++ b/crates/app-iced/src/platform/win32/transparency/tests.rs @@ -123,22 +123,29 @@ fn m116_corner_controlled_experiment() { } } -/// **M116 实测踩到的判据错误**:`DwmGetWindowAttribute` 在本机(RDP 断连)回读偏好返回 -/// `0x7FF8FFA9`——**正数**(严重性位为 0 = 成功),但 `hr == 0` 的写法会把它读成失败, -/// 于是「属性到底设上没有」变成猜谜。 +/// **成功判据的语义**:`HRESULT` 看严重性位(bit 31),不是「等于 0」——`S_FALSE`(1) 也算成功。 /// -/// 抓得住:把成功判据写回 `hr == 0`(那会让本机永远报「读不回来」)。 -/// 抓不住:DWM 具体返回哪个信息码(那是运行期读数)。 +/// ⚠️ 这条测试**不是**「本机 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 也是成功"); - // 失败码:严重性位为 1(i32 负数)。 + 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 实测踩过), + // `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); diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index 8a57c25..5e31902 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -53,12 +53,15 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"]; /// `palette_source`,M91 · D36/D37/D38):仍然只推 minor、不加迁移步骤——三者的出厂值 /// (不透明 / auto / 内置色卡)正是「老文件没有这些键」时的等价物。 /// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`): -/// 同一套纯增量语义——老文件缺该键 ⇒ 读默认 `system` ⇒ 跟随系统,与写进文件后行为一致。 -/// **向前兼容(旧版本读新文件)**:本仓的 [`super::catalog`] 是白名单校验, -/// 但读路径 (`SettingDocument::load`) 只**登记**陌生键、不报错也不删除(`RenderMode::Full` -/// 会把它们原样写回),所以旧版本打开新文件不会崩,只是看不到「直角」这一档; -/// 只有**显式 `store.set("appearance.window_corner", …)`** 在旧版本上才会 -/// 返回 `SettingError::UnknownKey`(新版本自己不会这么调)。 +/// 同一套纯增量语义——**新读旧**:老文件缺该键 ⇒ 读默认 `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, 5, 0) diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index edd118d..3788819 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -60,7 +60,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("SettingsAppearanceCorner", "Window corners", "窗口圆角"), ("SettingsAppearanceCornerSystem", "Follow system", "跟随系统"), ("SettingsAppearanceCornerSquare", "Square", "直角"), - ("SettingsAppearanceCornerHint", "Windows 11 and Windows Server 2025 round the window through the compositor, so the edges stay smooth. Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply, and we do not draw our own corners.", "Windows 11 与 Windows Server 2025 由系统合成器画圆角,边缘平滑;Windows 10 保持直角;macOS 与 Linux 交给桌面自己的规则,我们不自己画圆角。"), + ("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 交给桌面自己的规则,我们不自己画圆角。"), ("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 0dfb1c8..f6c7020 100644 --- a/crates/i18n/src/catalog/settings/appearance.rs +++ b/crates/i18n/src/catalog/settings/appearance.rs @@ -21,8 +21,8 @@ macro_rules! settings_appearance_entries { SettingsAppearanceCornerSystem => ("Follow system", "跟随系统"), SettingsAppearanceCornerSquare => ("Square", "直角"), SettingsAppearanceCornerHint => ( - "Windows 11 and Windows Server 2025 round the window through the compositor, so the edges stay smooth. Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply, and we do not draw our own corners.", - "Windows 11 与 Windows Server 2025 由系统合成器画圆角,边缘平滑;Windows 10 保持直角;macOS 与 Linux 交给桌面自己的规则,我们不自己画圆角。" + "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 交给桌面自己的规则,我们不自己画圆角。" ), } } }; From bc3a2b56a4d10ff43fb0023fb2362d5b0b7e739c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 18:27:23 +0800 Subject: [PATCH 4/9] =?UTF-8?q?docs(app-iced):=20M116=20r3=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=20=E2=80=94=E2=80=94=20=E5=8F=AA=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E9=87=8C=E7=9A=84=E3=80=8C=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E4=B8=8B=E5=B1=82=E9=94=AE=E3=80=8D=E6=95=B0=E9=87=8F=E4=B8=8E?= =?UTF-8?q?=E5=8F=96=E8=AF=81=E6=97=A5=E5=BF=97=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r3:p2 ×2,纯注释/文档漂移,**行为零改动、断言零改动**。 1. `app/appearance.rs:8,15-20,68` 与 `app/state.rs:170-171`:M116 之后 `AppearanceBase` 是**四个**字段(整窗 α / 标题栏三态 / 配色来源 / 窗口圆角), 走 `AppCommand::SetSetting` 的是**三个**键(标题栏三态还没有 UI 写者,D37 接线未落地)。 两处数字与列举按 grep 到的实际代码改(`AppearanceBase` 的 `pub` 字段数 = 4; `on_appearance` 的 match 守卫 = WindowOpacity / PaletteSource / Corner)。 2. `pages/appearance/mod.rs:41` 的取证件模板补 `corner=`:`[appearance]` 行确实有这一栏, 圆角还有一条独立管线 `[corner] corner=policy=… source=… os=… excluded_reason=… radius=system pref=… readback=… read_hr=… frame=… layered=…`(取值形状取自探针实跑, 见 corner/tests.rs 的格式单测),模板已按此补齐,并注明「看上去圆不圆」同样不可信。 门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-116`, `-j 4`,退出码逐个落盘): check 0 / fmt --check 0 / `cargo test -p unlucky-app-iced --lib` 0(447 passed, 0 failed, 2 ignored)。 行数:appearance.rs 130、state.rs 343、pages/appearance/mod.rs 180(均 ≤400)。 --- crates/app-iced/src/app/appearance.rs | 11 +++++++---- crates/app-iced/src/app/state.rs | 4 ++-- crates/app-iced/src/pages/appearance/mod.rs | 8 ++++++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/app-iced/src/app/appearance.rs b/crates/app-iced/src/app/appearance.rs index 0890af2..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,7 +67,8 @@ impl App { self.refresh_view(); match change.key() { - // 两个只读下层键:走 schema 指令(`SettingsBridge` 的结构里没有它们)。 + // 三个只读下层键(整窗 α / 配色来源 / 窗口圆角):走 schema 指令 + // (`SettingsBridge` 的结构里没有它们;标题栏三态还没有 UI 写者,不在此列)。 Some(_) if matches!( change, diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index c3bce41..ea33fc0 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -193,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_*` 显式灌进来。 diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 4d9e1e6..0492f47 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -39,8 +39,12 @@ //! ## 取证 //! //! 每一处改动都打一行 [`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=popup layered=on` +//! (见 `app::update::appearance` 与 `platform::win32::corner::format_line`)。数值是**下发前**的 +//! 字节/属性值(不是观感),因为在 RDP + 虚拟显示适配器上「看上去多透明 / 圆不圆」不可信。 +//! 截图矩阵用 `ULL_APPEARANCE_*` 环境变量灌值(见 [`state`] 的常量)。 pub mod alpha; pub mod background; From abc51a957bd24532c9cb1ec828e0e50800ec437e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 19:14:11 +0800 Subject: [PATCH 5/9] =?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 = 完全不透明) From 1cb36da1e2c6718781daefbf6d84e37c483ef9bc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 22:02:24 +0800 Subject: [PATCH 6/9] =?UTF-8?q?docs(presenter):=20M116=20r4=20P3=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20settings=5Fbridge=20=E7=9A=84=E3=80=8C?= =?UTF-8?q?=E4=B8=89=E4=B8=AA=E9=94=AE=E3=80=8D=E6=BC=82=E7=A7=BB=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E5=88=B0=E5=9B=9B=E4=B8=AA=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppearanceBase 自 M116 起有四个字段(整窗 α / 标题栏三态 / 配色来源 / 窗口圆角), load_base 的文档与测试名还写着「三个键」。只改注释与测试函数名,行为零改动。 --- crates/presenter/src/settings_bridge.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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( From 91f723a0191bf8de9f8c8bfb1ad8c1c0a51c8346 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 22:39:54 +0800 Subject: [PATCH 7/9] =?UTF-8?q?docs(app-iced):=20M116=20r4=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=97=A5=E5=BF=97/=E6=A8=A1=E6=9D=BF=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=E6=94=B9=E7=94=A8=E7=9C=9F=E8=BF=9B=E7=A8=8B=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E5=80=BC=EF=BC=88frame=3Doverlapped=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真进程两跑(`--software-renderer`,同一个 bin,只改整窗 α)拿到: α=1.0 → … frame=overlapped layered=off α<1 → … frame=overlapped layered=on suppressed_by=window_alpha 所以把两处仍拿 `frame=popup` 当示例的文档改成实测值: - `pages/appearance/mod.rs` 的取证模板:`frame=overlapped layered=on … suppressed_by=window_alpha`, 并注明真进程两条在 `DevDocs/artifacts/m116/README.md`、`frame=` 恒为 `overlapped`/`other`。 - `transparency/dwm.rs` 的 `style_summary` 示例:同上,并点明 `popup` 只属于带 owner 的弹出窗。 注释级改动,行为与断言零改动。 --- crates/app-iced/src/pages/appearance/mod.rs | 4 +++- crates/app-iced/src/platform/win32/transparency/dwm.rs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 0492f47..1d06014 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -41,7 +41,9 @@ //! 每一处改动都打一行 [`state::State::log_line`]:`[appearance] window_alpha=… panel_alpha=… //! byte=… corner=…`;圆角另有一条独立管线,行形如 //! `[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` +//! 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`] 的常量)。 diff --git a/crates/app-iced/src/platform/win32/transparency/dwm.rs b/crates/app-iced/src/platform/win32/transparency/dwm.rs index 7b85d3d..3990bae 100644 --- a/crates/app-iced/src/platform/win32/transparency/dwm.rs +++ b/crates/app-iced/src/platform/win32/transparency/dwm.rs @@ -268,7 +268,8 @@ pub const fn layered_label(ex_style: isize) -> &'static str { } } -/// 一个窗口的样式摘要(`frame=popup layered=on`);读不到时如实写 `frame=? layered=?`。 +/// 一个窗口的样式摘要(`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); From 67deb103b0d0143ed53e20be4fb5fdda19ff6cc6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 23:33:28 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(app-iced):=20M116=20r5=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=9C=86=E8=A7=92=E7=8A=B6=E6=80=81=E8=A1=8C?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=88=90=E3=80=8C=E7=8A=B6=E6=80=81=E3=80=8D?= =?UTF-8?q?=EF=BC=88=E6=8D=A2=E8=89=B2=E5=8A=A0=E7=B2=97=EF=BC=89+=20?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E6=AE=B5=E5=8E=BB=E6=8E=89=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=B9=B6=E5=8E=8B=E7=9F=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r5 复审 p2-1(图上)、p2-2(证据文档两处)。 ## P2-1:状态行要被当成「状态」看见 - 说明段 `SettingsAppearanceCornerHint` 里那句 α 说明**删掉**,只留平台分工(Win11/Server 2025 请求系统圆角、Win10 直角、macOS/Linux 看桌面合成器、我们不自绘)——英文从约 470 字符 / 7 行压到 3 行(1180 宽、100% DPI 实测)。 - `SettingsAppearanceCornerSuspended` 改成**只由状态行说**,且状态行不再是「说明段的续行」: 紧贴在档位行下面,颜色换成设计令牌 `Role::Tertiary`、字重加粗(`note_line` 保持原形, 新增 4 行的 `state_line` 助手)。档位本身照常显示用户选的值,不改成「直角」。 - 出图复核(四张,浅/深 × α=100/85):α=85 两张能看到一条**加粗的第三色**状态行, α=100 两张没有;状态行墨色由 `(63,76,87)`(与说明段同色)变为 `(116,108,140)`(Tertiary)。 ## P2-2:证据文档两处 - `DevDocs/artifacts/m116/probe-table.md` 的 `readback` 归因:`0x04000000`=`WS_CLIPSIBLINGS` 是 **`CreateWindowExW` 自己补的**(建完就在),`ShowWindow` **只补** `0x10000000`=`WS_VISIBLE`; 按 r5 复审的最小实验写清(req `0x00CF0000` → before `0x04CF0000` → after `0x14CF0000`), 读数一行未动。 - `corner/tests.rs` 里「本 mission 没构建 bin」改成指向 2026-11-04 的两次真跑记录 (`DevDocs/artifacts/m116/README.md` 的 `[corner] boot …` 两行)。 门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-116-r2`,`-j 2`,跑完已删): fmt --check 0 / `cargo test -p unlucky-app-iced --lib` 0(475 passed)/ `-p unlucky-i18n --lib` 0(24 passed)。 --- crates/app-iced/src/pages/appearance/section.rs | 16 ++++++++++------ .../app-iced/src/platform/win32/corner/tests.rs | 3 ++- crates/i18n/src/catalog/golden.rs | 2 +- crates/i18n/src/catalog/settings/appearance.rs | 4 ++-- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs index a35dfd5..86dfbbe 100644 --- a/crates/app-iced/src/pages/appearance/section.rs +++ b/crates/app-iced/src/pages/appearance/section.rs @@ -88,14 +88,12 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ), ], )); - // 档位照常显示用户选的值(不偷偷改成「直角」);α<1 时只补一句状态(裁定 A)。 - let mut notes = vec![t(Key::SettingsAppearanceCornerHint)]; + // 状态行(r5 P2-1):只在「跟随系统 + α<1」出现;Tertiary + 加粗,与下面的说明段刻意不同形。 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))); + rows = rows.push(state_line(ui, t(Key::SettingsAppearanceCornerSuspended))); } + 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(); @@ -283,6 +281,12 @@ fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color) } +/// 状态行(r5 P2-1):与说明段刻意不同形——`Role::Tertiary` + 强调字重,让「当前不生效」被当成状态看见。 +fn state_line<'a>(ui: &'a Ui, text: &str) -> Element<'a, Message> { + let tone = ui.color(Role::Tertiary); + frame::text_role_owned(ui, TypeRole::BodySmall, true, text.to_owned(), tone) +} + /// 一行「标签 + 值 + 滑块 + 说明」。 fn slider_row<'a>( ui: &'a Ui, diff --git a/crates/app-iced/src/platform/win32/corner/tests.rs b/crates/app-iced/src/platform/win32/corner/tests.rs index 5d70945..796db2d 100644 --- a/crates/app-iced/src/platform/win32/corner/tests.rs +++ b/crates/app-iced/src/platform/win32/corner/tests.rs @@ -159,7 +159,8 @@ fn the_log_line_carries_every_field() { /// /// 抓得住:字段被删/改名/顺序错乱(`excluded_reason` 少一栏、`radius=system` 丢了、 /// `frame` / `layered` 被挪出这一行)——这些都会让「没生效 vs 生效了看不见」的区分重新变成猜谜。 -/// 抓不住:产品进程里那一行到底长什么样(本 mission 没构建 bin;合并态由 tower 编 `--bins` 补)。 +/// 抓不住:**产品进程**里那一行(本测试只钉格式)。真值已有: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`, diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 2b4addd..cc631e9 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -60,7 +60,7 @@ 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. 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 交给桌面自己的规则,我们不自己画圆角。"), + ("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", "版本管理"), diff --git a/crates/i18n/src/catalog/settings/appearance.rs b/crates/i18n/src/catalog/settings/appearance.rs index bfa8f42..907e9a2 100644 --- a/crates/i18n/src/catalog/settings/appearance.rs +++ b/crates/i18n/src/catalog/settings/appearance.rs @@ -21,8 +21,8 @@ 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. 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 交给桌面自己的规则,我们不自己画圆角。" + "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.", From dc1fa8d08262e19a9051b9164bbcb56addd4c928 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 23:47:57 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix(app-iced):=20M116=20r5=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=9C=86=E8=A7=92=E7=8A=B6=E6=80=81=E4=BB=8E?= =?UTF-8?q?=E3=80=8C=E6=8D=A2=E8=89=B2=E6=AD=A3=E6=96=87=E3=80=8D=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E6=88=90=20tonal=20notice=20=E5=AE=B9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r5 复审的第二轮意见(改前那条是一行紫灰正文,与说明段只差色相、中间没有间距,读起来还是 「一段话的前两句」)。按 M3 惯例改成**容器**: - 新增 `pages/appearance/mod.rs::notice`:`Role::SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 左右 `Space::X2` / 上下 `Space::X1` 内边距,文字 `Role::OnSurfaceVariant` + 加粗。**只用既有角色与间距档位**,不新增令牌、不引入图标。 (放在模块根而不是 `section.rs`:后者已 395 行,400 行是硬顶。) - 状态行与下面的说明段之间再补 `Space::X1`(列间距 X1 + 这里 X1 = X2 总量), 让它与说明段在**形态**上分开,而不是靠色相。 - 出现条件不变:仅「跟随系统 + α<1」;档位照常显示用户选的值。 - 像素实测(浅色 α=85,1180×760 / 100% DPI):容器底色 `(216,228,239)`、卡片底 `(235,245,255)`;容器内文字 `(58,72,83)`;容器 y=372..411,说明段首行 y=428 ⇒ ink 间距 16px(布局 8px + 行高留白);α=100 两张图该底色出现次数为 0。 门禁:fmt --check 0 / `cargo test -p unlucky-app-iced --lib -j 2` 0(475 passed)。 --- crates/app-iced/src/pages/appearance/mod.rs | 37 +++++++++++++++++++ .../app-iced/src/pages/appearance/section.rs | 15 +++----- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 1d06014..1cbda1c 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -48,6 +48,18 @@ //! 字节/属性值(不是观感),因为在 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; @@ -56,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; diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs index 86dfbbe..04e3ada 100644 --- a/crates/app-iced/src/pages/appearance/section.rs +++ b/crates/app-iced/src/pages/appearance/section.rs @@ -20,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, }; @@ -88,9 +88,12 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ), ], )); - // 状态行(r5 P2-1):只在「跟随系统 + α<1」出现;Tertiary + 加粗,与下面的说明段刻意不同形。 + // **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。 + // 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。 if corner == WindowCorner::System && state.window_opacity < 1.0 { - rows = rows.push(state_line(ui, t(Key::SettingsAppearanceCornerSuspended))); + 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)); @@ -281,12 +284,6 @@ fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color) } -/// 状态行(r5 P2-1):与说明段刻意不同形——`Role::Tertiary` + 强调字重,让「当前不生效」被当成状态看见。 -fn state_line<'a>(ui: &'a Ui, text: &str) -> Element<'a, Message> { - let tone = ui.color(Role::Tertiary); - frame::text_role_owned(ui, TypeRole::BodySmall, true, text.to_owned(), tone) -} - /// 一行「标签 + 值 + 滑块 + 说明」。 fn slider_row<'a>( ui: &'a Ui,