feat(rust): native Win32 window opacity via SetLayeredWindowAttributes

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) <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-05-08 22:34:05 -04:00
parent ca4bd8da7a
commit faaf9a90ba
3 changed files with 62 additions and 16 deletions

1
src-tauri/Cargo.lock generated
View File

@@ -320,6 +320,7 @@ dependencies = [
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tokio", "tokio",
"windows",
] ]
[[package]] [[package]]

View File

@@ -30,3 +30,11 @@ tokio = { version = "1", features = ["time", "macros"] }
tauri-plugin-window-state = "2" tauri-plugin-window-state = "2"
tauri-plugin-global-shortcut = "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",
] }

View File

@@ -1,7 +1,15 @@
use std::time::Duration; use std::time::Duration;
use serde::Serialize; 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 OVERLAY_LABEL: &str = "overlay";
pub const EVT_CLICK_THROUGH_TOGGLED: &str = "click-through-toggled"; pub const EVT_CLICK_THROUGH_TOGGLED: &str = "click-through-toggled";
@@ -13,6 +21,41 @@ pub struct ClickThroughToggledPayload {
pub click_through: bool, 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<R: Runtime>(window: &WebviewWindow<R>, 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, /// 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. /// performs the opacity pulse, emits the toggled event). Returns the applied state.
/// ///
@@ -34,14 +77,11 @@ pub async fn apply_state<R: Runtime>(
.set_ignore_cursor_events(new_state) .set_ignore_cursor_events(new_state)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Opacity dip — visual pulse to signal the state change. // Opacity dip — visual pulse to signal the state change. The JS
// Tauri 2.11 does not expose set_opacity on WebviewWindow, so we apply // click-through-toggled handler restores opacity to the user's actual
// the dip by evaluating JS on the webview. The JS click-through-toggled // target after this event fires, so Rust deliberately does NOT set
// handler is responsible for restoring opacity to the user's actual // opacity back here.
// target — Rust deliberately does NOT set opacity back, so there is no let _ = apply_window_opacity(&overlay, 0.4);
// 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'");
tokio::time::sleep(Duration::from_millis(180)).await; tokio::time::sleep(Duration::from_millis(180)).await;
app.emit( app.emit(
@@ -105,8 +145,9 @@ pub fn get_hotkey_status(app: AppHandle) -> Result<crate::HotkeyStatus, String>
} }
/// Set window opacity. Tauri 2's JS WebviewWindow does NOT expose setOpacity, /// 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 /// so the JS layer routes through this command. Internally we use Win32's
/// set_opacity on the Rust WebviewWindow, so we apply the change via JS eval. /// SetLayeredWindowAttributes — true OS-level window transparency, not a
/// CSS shim. See `apply_window_opacity` for details.
#[tauri::command] #[tauri::command]
pub fn set_window_opacity<R: Runtime>( pub fn set_window_opacity<R: Runtime>(
app: AppHandle<R>, app: AppHandle<R>,
@@ -116,9 +157,5 @@ pub fn set_window_opacity<R: Runtime>(
let Some(window) = app.get_webview_window(&label) else { let Some(window) = app.get_webview_window(&label) else {
return Err(format!("window not found: {label}")); return Err(format!("window not found: {label}"));
}; };
window apply_window_opacity(&window, opacity)
.eval(&format!(
"document.documentElement.style.opacity='{opacity}'"
))
.map_err(|e| e.to_string())
} }