From 76a78615e1471c9644d78947127fa4924e8f1a75 Mon Sep 17 00:00:00 2001 From: cooper Date: Mon, 18 May 2026 23:43:22 +0800 Subject: [PATCH 1/3] fix(windows): clip capsule acrylic to visible regions --- .../windows-capsule-acrylic-contract.test.mjs | 59 ++++++++ openless-all/app/src-tauri/src/lib.rs | 136 ++++++++++++++++-- openless-all/app/src/lib/capsuleLayout.ts | 4 +- 3 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs diff --git a/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs b/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs new file mode 100644 index 00000000..6545417a --- /dev/null +++ b/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs @@ -0,0 +1,59 @@ +import { readFile } from 'node:fs/promises'; + +function assertMatch(source, pattern, name) { + if (!pattern.test(source)) { + throw new Error(`${name}: pattern ${pattern} not found`); + } +} + +const libRs = await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf-8'); +const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', import.meta.url), 'utf-8'); +const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8'); + +assertMatch( + libRs, + /fn apply_windows_capsule_acrylic_region\([\s\S]*?SetWindowRgn\(hwnd,\s*region,\s*true\)/, + 'windows capsule should clip its native Acrylic to pill/badge regions instead of tinting the whole host', +); + +assertMatch( + libRs, + /apply_windows_capsule_acrylic_region\(&capsule,\s*false\)[\s\S]*?apply_acrylic\(&capsule,\s*Some\(\(30,\s*32,\s*38,\s*140\)\)\)/, + 'windows capsule should keep Acrylic, but only after the native host region is clipped', +); + +assertMatch( + libRs, + /position_capsule_bottom_center[\s\S]*?apply_windows_capsule_acrylic_region\(window,\s*translation_active\)/, + 'windows capsule should update the Acrylic region when translation mode changes the host height', +); + +assertMatch( + libRs, + /CombineRgn\(region,\s*region,\s*badge_region,\s*RGN_OR\)/, + 'windows translation badge should be included as a separate rounded native region', +); + +assertMatch( + libRs, + /apply_acrylic\(&qa,\s*Some\(\(30,\s*32,\s*38,\s*140\)\)\)/, + 'windows QA window may keep Acrylic because its panel fills the native host', +); + +assertMatch( + capsuleLayoutTs, + /return \{ width: 196, height: 52, textWidth: 104, boxSizing: 'border-box' \};[\s\S]*?const horizontalInset = 12;[\s\S]*?width: pill\.width \+ horizontalInset \* 2,[\s\S]*?height: translationActive \? 118 : 84,[\s\S]*?bottomInset: 12,/, + 'windows capsule host must keep transparent margins for shadow, badge, and animation room', +); + +assertMatch( + capsuleTsx, + /const useBackdrop = true;[\s\S]*?background: 'rgba\(255, 255, 255, 0\.85\)'/, + 'windows capsule should keep the original translucent pill surface because native Acrylic is clipped, not removed', +); + +assertMatch( + capsuleTsx, + /return\s*\(\s*(window: &tauri::WebviewWindow) { } } +#[cfg(target_os = "windows")] +const WINDOWS_CAPSULE_PILL_WIDTH: f64 = 196.0; +#[cfg(target_os = "windows")] +const WINDOWS_CAPSULE_PILL_HEIGHT: f64 = 52.0; +#[cfg(target_os = "windows")] +const WINDOWS_CAPSULE_SIDE_INSET: f64 = 12.0; +#[cfg(target_os = "windows")] +const WINDOWS_CAPSULE_BOTTOM_INSET: f64 = 12.0; +#[cfg(target_os = "windows")] +const WINDOWS_CAPSULE_BADGE_WIDTH: f64 = 132.0; +#[cfg(target_os = "windows")] +const WINDOWS_CAPSULE_BADGE_HEIGHT: f64 = 24.0; +#[cfg(target_os = "windows")] +const WINDOWS_CAPSULE_BADGE_GAP: f64 = 8.0; + +#[cfg(target_os = "windows")] +#[derive(Clone, Copy)] +struct WindowsCapsuleRegionRect { + x: f64, + y: f64, + width: f64, + height: f64, + radius: f64, +} + +#[cfg(target_os = "windows")] +fn scale_region_coord(value: f64, scale: f64) -> i32 { + (value * scale).round() as i32 +} + +#[cfg(target_os = "windows")] +fn create_windows_capsule_round_region( + rect: WindowsCapsuleRegionRect, + scale: f64, +) -> windows::Win32::Graphics::Gdi::HRGN { + use windows::Win32::Graphics::Gdi::CreateRoundRectRgn; + + unsafe { + CreateRoundRectRgn( + scale_region_coord(rect.x, scale), + scale_region_coord(rect.y, scale), + scale_region_coord(rect.x + rect.width, scale), + scale_region_coord(rect.y + rect.height, scale), + scale_region_coord(rect.radius * 2.0, scale), + scale_region_coord(rect.radius * 2.0, scale), + ) + } +} + +#[cfg(target_os = "windows")] +fn apply_windows_capsule_acrylic_region( + window: &tauri::WebviewWindow, + translation_active: bool, +) -> Result<(), String> { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::HWND; + use windows::Win32::Graphics::Gdi::{CombineRgn, DeleteObject, SetWindowRgn, RGN_OR}; + + let handle = match window.window_handle().map(|h| h.as_raw()) { + Ok(RawWindowHandle::Win32(handle)) => handle, + Ok(other) => return Err(format!("unexpected raw window handle: {other:?}")), + Err(e) => return Err(format!("read raw window handle failed: {e}")), + }; + let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void); + let scale = window.scale_factor().unwrap_or(1.0); + let bounds = capsule_window_bounds(translation_active); + let pill_x = (bounds.width - WINDOWS_CAPSULE_PILL_WIDTH) / 2.0; + let pill_y = bounds.height - WINDOWS_CAPSULE_BOTTOM_INSET - WINDOWS_CAPSULE_PILL_HEIGHT; + let region = create_windows_capsule_round_region( + WindowsCapsuleRegionRect { + x: pill_x, + y: pill_y, + width: WINDOWS_CAPSULE_PILL_WIDTH, + height: WINDOWS_CAPSULE_PILL_HEIGHT, + radius: WINDOWS_CAPSULE_PILL_HEIGHT / 2.0, + }, + scale, + ); + if region.is_invalid() { + return Err("CreateRoundRectRgn for pill returned an invalid region".into()); + } + + if translation_active { + let badge_x = (bounds.width - WINDOWS_CAPSULE_BADGE_WIDTH) / 2.0; + let badge_y = pill_y - WINDOWS_CAPSULE_BADGE_GAP - WINDOWS_CAPSULE_BADGE_HEIGHT; + let badge_region = create_windows_capsule_round_region( + WindowsCapsuleRegionRect { + x: badge_x, + y: badge_y, + width: WINDOWS_CAPSULE_BADGE_WIDTH, + height: WINDOWS_CAPSULE_BADGE_HEIGHT, + radius: WINDOWS_CAPSULE_BADGE_HEIGHT / 2.0, + }, + scale, + ); + if badge_region.is_invalid() { + unsafe { + let _ = DeleteObject(region); + } + return Err("CreateRoundRectRgn for translation badge returned an invalid region".into()); + } + unsafe { + let _ = CombineRgn(region, region, badge_region, RGN_OR); + let _ = DeleteObject(badge_region); + } + } + + let applied = unsafe { SetWindowRgn(hwnd, region, true) }; + if applied == 0 { + unsafe { + let _ = DeleteObject(region); + } + return Err("SetWindowRgn failed".into()); + } + Ok(()) +} + #[tauri::command] fn restart_app(app: AppHandle) { // macOS:自动更新会让新装的 .app 带 com.apple.quarantine(无论 Tauri updater @@ -1201,6 +1315,10 @@ pub(crate) fn position_capsule_bottom_center( }; let bounds = capsule_window_bounds(translation_active); window.set_size(LogicalSize::new(bounds.width, bounds.height))?; + #[cfg(target_os = "windows")] + if let Err(e) = apply_windows_capsule_acrylic_region(window, translation_active) { + log::warn!("[capsule] acrylic region update failed: {e}"); + } let scale = monitor.scale_factor(); let size = monitor.size(); @@ -1223,14 +1341,12 @@ struct CapsuleWindowBounds { fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds { #[cfg(target_os = "windows")] { - const WINDOWS_CAPSULE_PILL_WIDTH: f64 = 196.0; - const WINDOWS_CAPSULE_SIDE_INSET: f64 = 12.0; CapsuleWindowBounds { // Keep the existing Windows hitbox width, but express it as // pill width (196) + symmetric 12px side insets for shadow room. width: WINDOWS_CAPSULE_PILL_WIDTH + WINDOWS_CAPSULE_SIDE_INSET * 2.0, height: if translation_active { 118.0 } else { 84.0 }, - bottom_inset: 12.0, + bottom_inset: WINDOWS_CAPSULE_BOTTOM_INSET, } } @@ -1250,7 +1366,7 @@ fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds { fn capsule_visual_height(_translation_active: bool) -> f64 { #[cfg(target_os = "windows")] { - 52.0 + WINDOWS_CAPSULE_PILL_HEIGHT } #[cfg(not(target_os = "windows"))] diff --git a/openless-all/app/src/lib/capsuleLayout.ts b/openless-all/app/src/lib/capsuleLayout.ts index cf116eb6..a6281d30 100644 --- a/openless-all/app/src/lib/capsuleLayout.ts +++ b/openless-all/app/src/lib/capsuleLayout.ts @@ -26,8 +26,8 @@ export interface CapsuleMessageLayout { export function getCapsulePillMetrics(os: OS): CapsulePillMetrics { if (os === 'win') { // Windows metrics describe the visible outer footprint of the pill. - // 与 macOS pill 接近以保持视觉密度一致;保留 ~4-5% 余量适配 Windows 字体 metrics。 - return { width: 180, height: 44, textWidth: 88, boxSizing: 'border-box' }; + // 与 Rust capsule_window_bounds 保持一致:220px host = 196px pill + 12px side insets. + return { width: 196, height: 52, textWidth: 104, boxSizing: 'border-box' }; } return { width: 176, height: 42, textWidth: 84, boxSizing: 'border-box' }; From 473581cf39e3c157c2226767606ac94181fa982b Mon Sep 17 00:00:00 2001 From: cooper Date: Tue, 19 May 2026 01:29:50 +0800 Subject: [PATCH 2/3] fix(windows): scope capsule material to DWM region --- .../windows-capsule-acrylic-contract.test.mjs | 26 ++++-- openless-all/app/src-tauri/src/lib.rs | 92 ++++++++++++++----- 2 files changed, 86 insertions(+), 32 deletions(-) diff --git a/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs b/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs index 6545417a..edb8019b 100644 --- a/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs +++ b/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs @@ -6,26 +6,38 @@ function assertMatch(source, pattern, name) { } } +function assertNotMatch(source, pattern, name) { + if (pattern.test(source)) { + throw new Error(`${name}: forbidden pattern ${pattern} found`); + } +} + const libRs = await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf-8'); const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', import.meta.url), 'utf-8'); const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8'); assertMatch( libRs, - /fn apply_windows_capsule_acrylic_region\([\s\S]*?SetWindowRgn\(hwnd,\s*region,\s*true\)/, - 'windows capsule should clip its native Acrylic to pill/badge regions instead of tinting the whole host', + /fn apply_windows_capsule_material_region\([\s\S]*?DwmEnableBlurBehindWindow\(hwnd,\s*&blur\)[\s\S]*?SetWindowRgn\(hwnd,\s*paint_region,\s*true\)/, + 'windows capsule should use a DWM blur region and a native paint region instead of tinting the whole host', ); assertMatch( libRs, - /apply_windows_capsule_acrylic_region\(&capsule,\s*false\)[\s\S]*?apply_acrylic\(&capsule,\s*Some\(\(30,\s*32,\s*38,\s*140\)\)\)/, - 'windows capsule should keep Acrylic, but only after the native host region is clipped', + /DwmSetWindowAttribute\([\s\S]*?DWMWA_SYSTEMBACKDROP_TYPE[\s\S]*?DWMSBT_NONE[\s\S]*?DWM_BB_ENABLE \| DWM_BB_BLURREGION/, + 'windows capsule should explicitly disable full-window Win11 system backdrop before enabling region-scoped native blur', +); + +assertNotMatch( + libRs, + /apply_acrylic\(&capsule,/, + 'windows capsule must not use window-vibrancy Acrylic because it paints a rectangular grey host on Win11', ); assertMatch( libRs, - /position_capsule_bottom_center[\s\S]*?apply_windows_capsule_acrylic_region\(window,\s*translation_active\)/, - 'windows capsule should update the Acrylic region when translation mode changes the host height', + /position_capsule_bottom_center[\s\S]*?apply_windows_capsule_material_region\(window,\s*translation_active\)/, + 'windows capsule should update the material region when translation mode changes the host height', ); assertMatch( @@ -49,7 +61,7 @@ assertMatch( assertMatch( capsuleTsx, /const useBackdrop = true;[\s\S]*?background: 'rgba\(255, 255, 255, 0\.85\)'/, - 'windows capsule should keep the original translucent pill surface because native Acrylic is clipped, not removed', + 'windows capsule should keep the original translucent pill surface because native material is region-scoped, not removed', ); assertMatch( diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 6e5b1817..acc175f8 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -131,12 +131,8 @@ pub fn run() { } #[cfg(target_os = "windows")] { - use window_vibrancy::apply_acrylic; - if let Err(e) = apply_windows_capsule_acrylic_region(&capsule, false) { - log::warn!("[capsule] acrylic region failed: {e}"); - } - if let Err(e) = apply_acrylic(&capsule, Some((30, 32, 38, 140))) { - log::warn!("[capsule] acrylic failed: {e}"); + if let Err(e) = apply_windows_capsule_material_region(&capsule, false) { + log::warn!("[capsule] material region failed: {e}"); } } let _ = capsule.hide(); @@ -773,22 +769,13 @@ fn create_windows_capsule_round_region( } #[cfg(target_os = "windows")] -fn apply_windows_capsule_acrylic_region( - window: &tauri::WebviewWindow, +fn create_windows_capsule_region( + bounds: CapsuleWindowBounds, translation_active: bool, -) -> Result<(), String> { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - use windows::Win32::Foundation::HWND; - use windows::Win32::Graphics::Gdi::{CombineRgn, DeleteObject, SetWindowRgn, RGN_OR}; + scale: f64, +) -> Result { + use windows::Win32::Graphics::Gdi::{CombineRgn, DeleteObject, RGN_OR}; - let handle = match window.window_handle().map(|h| h.as_raw()) { - Ok(RawWindowHandle::Win32(handle)) => handle, - Ok(other) => return Err(format!("unexpected raw window handle: {other:?}")), - Err(e) => return Err(format!("read raw window handle failed: {e}")), - }; - let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void); - let scale = window.scale_factor().unwrap_or(1.0); - let bounds = capsule_window_bounds(translation_active); let pill_x = (bounds.width - WINDOWS_CAPSULE_PILL_WIDTH) / 2.0; let pill_y = bounds.height - WINDOWS_CAPSULE_BOTTOM_INSET - WINDOWS_CAPSULE_PILL_HEIGHT; let region = create_windows_capsule_round_region( @@ -822,7 +809,9 @@ fn apply_windows_capsule_acrylic_region( unsafe { let _ = DeleteObject(region); } - return Err("CreateRoundRectRgn for translation badge returned an invalid region".into()); + return Err( + "CreateRoundRectRgn for translation badge returned an invalid region".into(), + ); } unsafe { let _ = CombineRgn(region, region, badge_region, RGN_OR); @@ -830,10 +819,63 @@ fn apply_windows_capsule_acrylic_region( } } - let applied = unsafe { SetWindowRgn(hwnd, region, true) }; + Ok(region) +} + +#[cfg(target_os = "windows")] +fn apply_windows_capsule_material_region( + window: &tauri::WebviewWindow, + translation_active: bool, +) -> Result<(), String> { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::{BOOL, HWND}; + use windows::Win32::Graphics::Dwm::{ + DwmEnableBlurBehindWindow, DwmSetWindowAttribute, DWMSBT_NONE, DWMWA_SYSTEMBACKDROP_TYPE, + DWM_BB_BLURREGION, DWM_BB_ENABLE, DWM_BLURBEHIND, + }; + use windows::Win32::Graphics::Gdi::{DeleteObject, SetWindowRgn}; + + let handle = match window.window_handle().map(|h| h.as_raw()) { + Ok(RawWindowHandle::Win32(handle)) => handle, + Ok(other) => return Err(format!("unexpected raw window handle: {other:?}")), + Err(e) => return Err(format!("read raw window handle failed: {e}")), + }; + let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void); + let bounds = capsule_window_bounds(translation_active); + let scale = window.scale_factor().unwrap_or(1.0); + + // Win11's DWMWA_SYSTEMBACKDROP_TYPE / window-vibrancy Acrylic paints the full + // rectangular HWND and ignores SetWindowRgn, which creates a visible grey host + // behind the capsule. Use DWM's native blur-region path instead: it keeps a + // Windows material inside the rounded visible region while leaving host + // margins genuinely transparent. + let blur_region = create_windows_capsule_region(bounds, translation_active, scale)?; + let disable_backdrop = DWMSBT_NONE; + unsafe { + let _ = DwmSetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE, + &disable_backdrop as *const _ as *const core::ffi::c_void, + std::mem::size_of_val(&disable_backdrop) as u32, + ); + let blur = DWM_BLURBEHIND { + dwFlags: DWM_BB_ENABLE | DWM_BB_BLURREGION, + fEnable: BOOL(1), + hRgnBlur: blur_region, + fTransitionOnMaximized: BOOL(0), + }; + if let Err(e) = DwmEnableBlurBehindWindow(hwnd, &blur) { + let _ = DeleteObject(blur_region); + return Err(format!("DwmEnableBlurBehindWindow failed: {e}")); + } + let _ = DeleteObject(blur_region); + } + + let paint_region = create_windows_capsule_region(bounds, translation_active, scale)?; + let applied = unsafe { SetWindowRgn(hwnd, paint_region, true) }; if applied == 0 { unsafe { - let _ = DeleteObject(region); + let _ = DeleteObject(paint_region); } return Err("SetWindowRgn failed".into()); } @@ -1316,8 +1358,8 @@ pub(crate) fn position_capsule_bottom_center( let bounds = capsule_window_bounds(translation_active); window.set_size(LogicalSize::new(bounds.width, bounds.height))?; #[cfg(target_os = "windows")] - if let Err(e) = apply_windows_capsule_acrylic_region(window, translation_active) { - log::warn!("[capsule] acrylic region update failed: {e}"); + if let Err(e) = apply_windows_capsule_material_region(window, translation_active) { + log::warn!("[capsule] material region update failed: {e}"); } let scale = monitor.scale_factor(); From 4b28d92acc7dbe9400dd7952b8828e823e722d2f Mon Sep 17 00:00:00 2001 From: cooper Date: Tue, 19 May 2026 01:59:26 +0800 Subject: [PATCH 3/3] fix(windows): restore capsule transparent host boundary --- .../windows-capsule-acrylic-contract.test.mjs | 32 +-- .../app/scripts/windows-ui-config.test.mjs | 8 +- openless-all/app/src-tauri/src/lib.rs | 192 +----------------- openless-all/app/src/components/Capsule.tsx | 6 +- .../app/src/lib/capsuleLayout.test.ts | 21 +- openless-all/app/src/lib/capsuleLayout.ts | 7 +- 6 files changed, 43 insertions(+), 223 deletions(-) diff --git a/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs b/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs index edb8019b..da07fef9 100644 --- a/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs +++ b/openless-all/app/scripts/windows-capsule-acrylic-contract.test.mjs @@ -16,34 +16,16 @@ const libRs = await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url) const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', import.meta.url), 'utf-8'); const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8'); -assertMatch( - libRs, - /fn apply_windows_capsule_material_region\([\s\S]*?DwmEnableBlurBehindWindow\(hwnd,\s*&blur\)[\s\S]*?SetWindowRgn\(hwnd,\s*paint_region,\s*true\)/, - 'windows capsule should use a DWM blur region and a native paint region instead of tinting the whole host', -); - -assertMatch( - libRs, - /DwmSetWindowAttribute\([\s\S]*?DWMWA_SYSTEMBACKDROP_TYPE[\s\S]*?DWMSBT_NONE[\s\S]*?DWM_BB_ENABLE \| DWM_BB_BLURREGION/, - 'windows capsule should explicitly disable full-window Win11 system backdrop before enabling region-scoped native blur', -); - assertNotMatch( libRs, /apply_acrylic\(&capsule,/, 'windows capsule must not use window-vibrancy Acrylic because it paints a rectangular grey host on Win11', ); -assertMatch( - libRs, - /position_capsule_bottom_center[\s\S]*?apply_windows_capsule_material_region\(window,\s*translation_active\)/, - 'windows capsule should update the material region when translation mode changes the host height', -); - -assertMatch( +assertNotMatch( libRs, - /CombineRgn\(region,\s*region,\s*badge_region,\s*RGN_OR\)/, - 'windows translation badge should be included as a separate rounded native region', + /DwmEnableBlurBehindWindow|DWMWA_SYSTEMBACKDROP_TYPE|SetWindowRgn/, + 'windows capsule must not use HWND-level DWM material or native regions; the DOM pill owns the visible shape', ); assertMatch( @@ -54,14 +36,14 @@ assertMatch( assertMatch( capsuleLayoutTs, - /return \{ width: 196, height: 52, textWidth: 104, boxSizing: 'border-box' \};[\s\S]*?const horizontalInset = 12;[\s\S]*?width: pill\.width \+ horizontalInset \* 2,[\s\S]*?height: translationActive \? 118 : 84,[\s\S]*?bottomInset: 12,/, - 'windows capsule host must keep transparent margins for shadow, badge, and animation room', + /return \{ width: 180, height: 44, textWidth: 88, boxSizing: 'border-box' \};[\s\S]*?const horizontalInset = 12;[\s\S]*?width: 220,[\s\S]*?height: translationActive \? 118 : 84,[\s\S]*?bottomInset: 12,/, + 'windows capsule should keep the original compact DOM pill inside a transparent native host', ); assertMatch( capsuleTsx, - /const useBackdrop = true;[\s\S]*?background: 'rgba\(255, 255, 255, 0\.85\)'/, - 'windows capsule should keep the original translucent pill surface because native material is region-scoped, not removed', + /const useBackdrop = os !== 'win';[\s\S]*?background: os === 'win' \? 'rgba\(255, 255, 255, 0\.96\)' : 'rgba\(255, 255, 255, 0\.85\)'/, + 'windows capsule pill should use an opaque DOM surface instead of WebView2 backdrop-filter over a transparent host', ); assertMatch( diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index 4d9ddb61..cf81e2b6 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -108,7 +108,11 @@ if (!/export function getCapsuleHostMetrics\(\s*os: OS,\s*translationActive: boo throw new Error('capsule layout should define explicit host metrics separate from the visible pill metrics'); } -if (!/if \(os === 'win'\)\s*\{[\s\S]*?const horizontalInset = 12;[\s\S]*?const pill = getCapsulePillMetrics\(os\);[\s\S]*?width: pill\.width \+ horizontalInset \* 2,[\s\S]*?height: translationActive \? 118 : 84,[\s\S]*?horizontalInset,[\s\S]*?bottomInset: 12,[\s\S]*?badgeGap: 8,[\s\S]*?boxSizing: 'border-box',[\s\S]*?\}/.test(capsuleLayoutTs)) { +if (!/if \(os === 'win'\)\s*\{[\s\S]*?return \{ width: 180, height: 44, textWidth: 88, boxSizing: 'border-box' \};[\s\S]*?\}/.test(capsuleLayoutTs)) { + throw new Error('windows capsule should keep the original compact visible pill metrics'); +} + +if (!/if \(os === 'win'\)\s*\{[\s\S]*?const horizontalInset = 12;[\s\S]*?width: 220,[\s\S]*?height: translationActive \? 118 : 84,[\s\S]*?horizontalInset,[\s\S]*?bottomInset: 12,[\s\S]*?badgeGap: 8,[\s\S]*?boxSizing: 'border-box',[\s\S]*?\}/.test(capsuleLayoutTs)) { throw new Error('windows capsule host metrics should leave room for shadow and badge geometry'); } @@ -132,7 +136,7 @@ if (!/hostMetrics\.bottomInset \+ metrics\.height \+ hostMetrics\.badgeGap/.test throw new Error('windows translation badge should anchor from the shared host inset instead of a fixed center-based offset'); } -if (!/#\[cfg\(target_os = "windows"\)\][\s\S]*?const WINDOWS_CAPSULE_PILL_WIDTH: f64 = 196\.0;[\s\S]*?const WINDOWS_CAPSULE_SIDE_INSET: f64 = 12\.0;[\s\S]*?width: WINDOWS_CAPSULE_PILL_WIDTH \+ WINDOWS_CAPSULE_SIDE_INSET \* 2\.0,[\s\S]*?height: if translation_active \{ 118\.0 \} else \{ 84\.0 \},[\s\S]*?bottom_inset: 12\.0,/.test(libRs)) { +if (!/#\[cfg\(target_os = "windows"\)\]\s*\{[\s\S]*?const WINDOWS_CAPSULE_SIDE_INSET: f64 = 12\.0;[\s\S]*?width: 220\.0,[\s\S]*?height: if translation_active \{ 118\.0 \} else \{ 84\.0 \},[\s\S]*?bottom_inset: WINDOWS_CAPSULE_SIDE_INSET,/.test(libRs)) { throw new Error('windows runtime capsule bounds should leave room for the native shadow while keeping a fixed visual pill'); } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index acc175f8..5dd120f0 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -129,12 +129,9 @@ pub fn run() { if let Err(e) = position_capsule_bottom_center(&capsule, false) { log::warn!("[capsule] position failed: {e}"); } - #[cfg(target_os = "windows")] - { - if let Err(e) = apply_windows_capsule_material_region(&capsule, false) { - log::warn!("[capsule] material region failed: {e}"); - } - } + // Keep the Windows capsule host fully transparent. The native host is larger + // than the visible pill to reserve shadow, badge, and animation room; applying + // a HWND-level material here paints those transparent margins as a gray box. let _ = capsule.hide(); } @@ -148,9 +145,8 @@ pub fn run() { } #[cfg(target_os = "macos")] make_qa_window_draggable_macos(&qa); - // 同 capsule:Windows 下 QA 浮窗也走 Acrylic 兜底。QA 面板自身 - // 已经把 alpha 拉到 0.97(QaPanel.tsx:623),主要是防止 0.03 缝隙 - // 透到桌面、以及 Win10 上完全无毛玻璃感的兜底。 + // QA fills its native host, so Windows Acrylic remains a useful fallback here. + // Capsule is different: its host has transparent margins around a smaller pill. #[cfg(target_os = "windows")] { use window_vibrancy::apply_acrylic; @@ -719,169 +715,6 @@ fn apply_windows_caption_color(window: &tauri::WebviewWindow) { } } -#[cfg(target_os = "windows")] -const WINDOWS_CAPSULE_PILL_WIDTH: f64 = 196.0; -#[cfg(target_os = "windows")] -const WINDOWS_CAPSULE_PILL_HEIGHT: f64 = 52.0; -#[cfg(target_os = "windows")] -const WINDOWS_CAPSULE_SIDE_INSET: f64 = 12.0; -#[cfg(target_os = "windows")] -const WINDOWS_CAPSULE_BOTTOM_INSET: f64 = 12.0; -#[cfg(target_os = "windows")] -const WINDOWS_CAPSULE_BADGE_WIDTH: f64 = 132.0; -#[cfg(target_os = "windows")] -const WINDOWS_CAPSULE_BADGE_HEIGHT: f64 = 24.0; -#[cfg(target_os = "windows")] -const WINDOWS_CAPSULE_BADGE_GAP: f64 = 8.0; - -#[cfg(target_os = "windows")] -#[derive(Clone, Copy)] -struct WindowsCapsuleRegionRect { - x: f64, - y: f64, - width: f64, - height: f64, - radius: f64, -} - -#[cfg(target_os = "windows")] -fn scale_region_coord(value: f64, scale: f64) -> i32 { - (value * scale).round() as i32 -} - -#[cfg(target_os = "windows")] -fn create_windows_capsule_round_region( - rect: WindowsCapsuleRegionRect, - scale: f64, -) -> windows::Win32::Graphics::Gdi::HRGN { - use windows::Win32::Graphics::Gdi::CreateRoundRectRgn; - - unsafe { - CreateRoundRectRgn( - scale_region_coord(rect.x, scale), - scale_region_coord(rect.y, scale), - scale_region_coord(rect.x + rect.width, scale), - scale_region_coord(rect.y + rect.height, scale), - scale_region_coord(rect.radius * 2.0, scale), - scale_region_coord(rect.radius * 2.0, scale), - ) - } -} - -#[cfg(target_os = "windows")] -fn create_windows_capsule_region( - bounds: CapsuleWindowBounds, - translation_active: bool, - scale: f64, -) -> Result { - use windows::Win32::Graphics::Gdi::{CombineRgn, DeleteObject, RGN_OR}; - - let pill_x = (bounds.width - WINDOWS_CAPSULE_PILL_WIDTH) / 2.0; - let pill_y = bounds.height - WINDOWS_CAPSULE_BOTTOM_INSET - WINDOWS_CAPSULE_PILL_HEIGHT; - let region = create_windows_capsule_round_region( - WindowsCapsuleRegionRect { - x: pill_x, - y: pill_y, - width: WINDOWS_CAPSULE_PILL_WIDTH, - height: WINDOWS_CAPSULE_PILL_HEIGHT, - radius: WINDOWS_CAPSULE_PILL_HEIGHT / 2.0, - }, - scale, - ); - if region.is_invalid() { - return Err("CreateRoundRectRgn for pill returned an invalid region".into()); - } - - if translation_active { - let badge_x = (bounds.width - WINDOWS_CAPSULE_BADGE_WIDTH) / 2.0; - let badge_y = pill_y - WINDOWS_CAPSULE_BADGE_GAP - WINDOWS_CAPSULE_BADGE_HEIGHT; - let badge_region = create_windows_capsule_round_region( - WindowsCapsuleRegionRect { - x: badge_x, - y: badge_y, - width: WINDOWS_CAPSULE_BADGE_WIDTH, - height: WINDOWS_CAPSULE_BADGE_HEIGHT, - radius: WINDOWS_CAPSULE_BADGE_HEIGHT / 2.0, - }, - scale, - ); - if badge_region.is_invalid() { - unsafe { - let _ = DeleteObject(region); - } - return Err( - "CreateRoundRectRgn for translation badge returned an invalid region".into(), - ); - } - unsafe { - let _ = CombineRgn(region, region, badge_region, RGN_OR); - let _ = DeleteObject(badge_region); - } - } - - Ok(region) -} - -#[cfg(target_os = "windows")] -fn apply_windows_capsule_material_region( - window: &tauri::WebviewWindow, - translation_active: bool, -) -> Result<(), String> { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - use windows::Win32::Foundation::{BOOL, HWND}; - use windows::Win32::Graphics::Dwm::{ - DwmEnableBlurBehindWindow, DwmSetWindowAttribute, DWMSBT_NONE, DWMWA_SYSTEMBACKDROP_TYPE, - DWM_BB_BLURREGION, DWM_BB_ENABLE, DWM_BLURBEHIND, - }; - use windows::Win32::Graphics::Gdi::{DeleteObject, SetWindowRgn}; - - let handle = match window.window_handle().map(|h| h.as_raw()) { - Ok(RawWindowHandle::Win32(handle)) => handle, - Ok(other) => return Err(format!("unexpected raw window handle: {other:?}")), - Err(e) => return Err(format!("read raw window handle failed: {e}")), - }; - let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void); - let bounds = capsule_window_bounds(translation_active); - let scale = window.scale_factor().unwrap_or(1.0); - - // Win11's DWMWA_SYSTEMBACKDROP_TYPE / window-vibrancy Acrylic paints the full - // rectangular HWND and ignores SetWindowRgn, which creates a visible grey host - // behind the capsule. Use DWM's native blur-region path instead: it keeps a - // Windows material inside the rounded visible region while leaving host - // margins genuinely transparent. - let blur_region = create_windows_capsule_region(bounds, translation_active, scale)?; - let disable_backdrop = DWMSBT_NONE; - unsafe { - let _ = DwmSetWindowAttribute( - hwnd, - DWMWA_SYSTEMBACKDROP_TYPE, - &disable_backdrop as *const _ as *const core::ffi::c_void, - std::mem::size_of_val(&disable_backdrop) as u32, - ); - let blur = DWM_BLURBEHIND { - dwFlags: DWM_BB_ENABLE | DWM_BB_BLURREGION, - fEnable: BOOL(1), - hRgnBlur: blur_region, - fTransitionOnMaximized: BOOL(0), - }; - if let Err(e) = DwmEnableBlurBehindWindow(hwnd, &blur) { - let _ = DeleteObject(blur_region); - return Err(format!("DwmEnableBlurBehindWindow failed: {e}")); - } - let _ = DeleteObject(blur_region); - } - - let paint_region = create_windows_capsule_region(bounds, translation_active, scale)?; - let applied = unsafe { SetWindowRgn(hwnd, paint_region, true) }; - if applied == 0 { - unsafe { - let _ = DeleteObject(paint_region); - } - return Err("SetWindowRgn failed".into()); - } - Ok(()) -} - #[tauri::command] fn restart_app(app: AppHandle) { // macOS:自动更新会让新装的 .app 带 com.apple.quarantine(无论 Tauri updater @@ -1357,10 +1190,6 @@ pub(crate) fn position_capsule_bottom_center( }; let bounds = capsule_window_bounds(translation_active); window.set_size(LogicalSize::new(bounds.width, bounds.height))?; - #[cfg(target_os = "windows")] - if let Err(e) = apply_windows_capsule_material_region(window, translation_active) { - log::warn!("[capsule] material region update failed: {e}"); - } let scale = monitor.scale_factor(); let size = monitor.size(); @@ -1383,12 +1212,13 @@ struct CapsuleWindowBounds { fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds { #[cfg(target_os = "windows")] { + const WINDOWS_CAPSULE_SIDE_INSET: f64 = 12.0; CapsuleWindowBounds { - // Keep the existing Windows hitbox width, but express it as - // pill width (196) + symmetric 12px side insets for shadow room. - width: WINDOWS_CAPSULE_PILL_WIDTH + WINDOWS_CAPSULE_SIDE_INSET * 2.0, + // Keep the existing Windows hitbox width while reserving transparent + // margins for the DOM pill shadow and translation badge animation. + width: 220.0, height: if translation_active { 118.0 } else { 84.0 }, - bottom_inset: WINDOWS_CAPSULE_BOTTOM_INSET, + bottom_inset: WINDOWS_CAPSULE_SIDE_INSET, } } @@ -1408,7 +1238,7 @@ fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds { fn capsule_visual_height(_translation_active: bool) -> f64 { #[cfg(target_os = "windows")] { - WINDOWS_CAPSULE_PILL_HEIGHT + 52.0 } #[cfg(not(target_os = "windows"))] diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 283ce712..1cb4aed6 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -244,7 +244,7 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0; const scale = os === 'win' ? 1 : 1 + ambient * 0.018; const shadowAlpha = 0.20 + ambient * 0.10; - const useBackdrop = true; + const useBackdrop = os !== 'win'; return (
(actual: T, expected: T, name: string) { } const winMetrics = getCapsulePillMetrics('win'); -assertEqual(winMetrics.width, 196, 'windows capsule widens pill'); -assertEqual(winMetrics.height, 52, 'windows capsule increases pill height'); -assertEqual(winMetrics.textWidth, 104, 'windows capsule keeps side controls clear'); +assertEqual(winMetrics.width, 180, 'windows capsule keeps original pill width'); +assertEqual(winMetrics.height, 44, 'windows capsule keeps original pill height'); +assertEqual(winMetrics.textWidth, 88, 'windows capsule keeps original text slot'); assertEqual(winMetrics.boxSizing, 'border-box', 'windows capsule pill width is an outer border-box metric'); const winHost = getCapsuleHostMetrics('win', false); @@ -22,14 +22,19 @@ assertEqual(winHost.height, 84, 'windows capsule host keeps regular height'); assertEqual(winHost.horizontalInset, 12, 'windows capsule host keeps symmetric shadow insets'); assertEqual(winHost.boxSizing, 'border-box', 'windows capsule host inset is reserved inside the native width'); assertEqual( - winHost.width, - winMetrics.width + winHost.horizontalInset * 2, - 'windows capsule host width derives from pill width plus symmetric side insets', + winHost.width - winHost.horizontalInset * 2, + 196, + 'windows capsule host leaves extra centering room around the original pill', +); +assertEqual( + winHost.width - winHost.horizontalInset * 2 - winMetrics.width, + 16, + 'windows capsule host keeps the visible pill compact inside the transparent host', ); assertEqual( - winHost.width - winHost.horizontalInset * 2, winMetrics.width, - 'windows capsule host keeps the visible pill width after reserving side insets', + 180, + 'windows capsule pill is independent from the host hitbox width', ); const winHostWithTranslation = getCapsuleHostMetrics('win', true); diff --git a/openless-all/app/src/lib/capsuleLayout.ts b/openless-all/app/src/lib/capsuleLayout.ts index a6281d30..5663552a 100644 --- a/openless-all/app/src/lib/capsuleLayout.ts +++ b/openless-all/app/src/lib/capsuleLayout.ts @@ -26,8 +26,8 @@ export interface CapsuleMessageLayout { export function getCapsulePillMetrics(os: OS): CapsulePillMetrics { if (os === 'win') { // Windows metrics describe the visible outer footprint of the pill. - // 与 Rust capsule_window_bounds 保持一致:220px host = 196px pill + 12px side insets. - return { width: 196, height: 52, textWidth: 104, boxSizing: 'border-box' }; + // Keep the original compact capsule shape; the native host owns only transparent room. + return { width: 180, height: 44, textWidth: 88, boxSizing: 'border-box' }; } return { width: 176, height: 42, textWidth: 84, boxSizing: 'border-box' }; @@ -41,9 +41,8 @@ export function getCapsuleHostMetrics( ): CapsuleHostMetrics { if (os === 'win') { const horizontalInset = 12; - const pill = getCapsulePillMetrics(os); return { - width: pill.width + horizontalInset * 2, + width: 220, height: translationActive ? 118 : 84, horizontalInset, bottomInset: 12,