From faaf9a90baf362cd1604d1ecda3eb2a9d26862e7 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 8 May 2026 22:34:05 -0400 Subject: [PATCH] feat(rust): native Win32 window opacity via SetLayeredWindowAttributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tauri 2.11 doesn't expose set_opacity on its WebviewWindow (open issue tauri-apps/tauri#3279). Drop down to the Win32 API directly: ensure WS_EX_LAYERED is set on the overlay HWND, then SetLayeredWindowAttributes with an 0..255 alpha. This is true OS-level window translucency — the same call Tauri will eventually wrap upstream — not a CSS shim. windows crate version is pinned to 0.61 to match Tauri 2.11.x's transitive dependency, so WebviewWindow::hwnd() returns a compatible HWND with no diamond-dependency conflict. Replaces the earlier eval-based opacity workaround in apply_state and set_window_opacity. Both call sites now go through apply_window_opacity. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 8 ++++ src-tauri/src/commands/overlay.rs | 69 ++++++++++++++++++++++++------- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 998e100..8411251 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -320,6 +320,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-window-state", "tokio", + "windows", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f807260..9fdec2a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,3 +30,11 @@ tokio = { version = "1", features = ["time", "macros"] } tauri-plugin-window-state = "2" tauri-plugin-global-shortcut = "2" +[target."cfg(windows)".dependencies] +# Match Tauri 2.11.x's transitive windows version so `WebviewWindow::hwnd()` +# returns a compatible HWND (no diamond-dependency conflict). +windows = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_UI_WindowsAndMessaging", +] } + diff --git a/src-tauri/src/commands/overlay.rs b/src-tauri/src/commands/overlay.rs index 179668c..b5f6b90 100644 --- a/src-tauri/src/commands/overlay.rs +++ b/src-tauri/src/commands/overlay.rs @@ -1,7 +1,15 @@ use std::time::Duration; use serde::Serialize; -use tauri::{AppHandle, Emitter, Manager, Runtime}; +use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow}; + +#[cfg(windows)] +use windows::Win32::Foundation::HWND; +#[cfg(windows)] +use windows::Win32::UI::WindowsAndMessaging::{ + GetWindowLongPtrW, SetLayeredWindowAttributes, SetWindowLongPtrW, GWL_EXSTYLE, LWA_ALPHA, + WS_EX_LAYERED, +}; pub const OVERLAY_LABEL: &str = "overlay"; pub const EVT_CLICK_THROUGH_TOGGLED: &str = "click-through-toggled"; @@ -13,6 +21,41 @@ pub struct ClickThroughToggledPayload { pub click_through: bool, } +/// Native window opacity via SetLayeredWindowAttributes. +/// +/// Tauri 2.11 doesn't expose set_opacity on its WebviewWindow (open issue +/// tauri-apps/tauri#3279). We drop down to Win32 directly: ensure the window +/// has the WS_EX_LAYERED extended style, then set its alpha. This is the +/// same call Tauri will eventually wrap when upstream support lands — +/// migrating later is a one-liner. +/// +/// `opacity` is clamped to 0.0..=1.0 and translated to a 0..=255 alpha byte. +fn apply_window_opacity(window: &WebviewWindow, opacity: f32) -> Result<(), String> { + let clamped = opacity.clamp(0.0, 1.0); + + #[cfg(windows)] + { + let hwnd: HWND = window.hwnd().map_err(|e| e.to_string())?; + let alpha: u8 = (clamped * 255.0).round() as u8; + unsafe { + let current_ex_style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + let layered_bit = WS_EX_LAYERED.0 as isize; + if (current_ex_style & layered_bit) == 0 { + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, current_ex_style | layered_bit); + } + SetLayeredWindowAttributes(hwnd, windows::Win32::Foundation::COLORREF(0), alpha, LWA_ALPHA) + .map_err(|e| e.to_string())?; + } + Ok(()) + } + + #[cfg(not(windows))] + { + let _ = (window, clamped); + Err("window opacity is only implemented on Windows".to_string()) + } +} + /// Applies a specific click-through state to the overlay (sets ignore_cursor_events, /// performs the opacity pulse, emits the toggled event). Returns the applied state. /// @@ -34,14 +77,11 @@ pub async fn apply_state( .set_ignore_cursor_events(new_state) .map_err(|e| e.to_string())?; - // Opacity dip — visual pulse to signal the state change. - // Tauri 2.11 does not expose set_opacity on WebviewWindow, so we apply - // the dip by evaluating JS on the webview. The JS click-through-toggled - // handler is responsible for restoring opacity to the user's actual - // target — Rust deliberately does NOT set opacity back, so there is no - // possibility of the global-shortcut path "snapping back" to a wrong - // value (we don't have a current-opacity getter). - let _ = overlay.eval("document.documentElement.style.opacity='0.4'"); + // Opacity dip — visual pulse to signal the state change. The JS + // click-through-toggled handler restores opacity to the user's actual + // target after this event fires, so Rust deliberately does NOT set + // opacity back here. + let _ = apply_window_opacity(&overlay, 0.4); tokio::time::sleep(Duration::from_millis(180)).await; app.emit( @@ -105,8 +145,9 @@ pub fn get_hotkey_status(app: AppHandle) -> Result } /// Set window opacity. Tauri 2's JS WebviewWindow does NOT expose setOpacity, -/// so the JS layer routes through this command. Tauri 2.11 also doesn't expose -/// set_opacity on the Rust WebviewWindow, so we apply the change via JS eval. +/// so the JS layer routes through this command. Internally we use Win32's +/// SetLayeredWindowAttributes — true OS-level window transparency, not a +/// CSS shim. See `apply_window_opacity` for details. #[tauri::command] pub fn set_window_opacity( app: AppHandle, @@ -116,9 +157,5 @@ pub fn set_window_opacity( let Some(window) = app.get_webview_window(&label) else { return Err(format!("window not found: {label}")); }; - window - .eval(&format!( - "document.documentElement.style.opacity='{opacity}'" - )) - .map_err(|e| e.to_string()) + apply_window_opacity(&window, opacity) }