Handy/src-tauri/src/overlay.rs
Danielalnajjar ee571b6810
fix: re-assert always-on-top for overlay on Windows (#383)
* fix: re-assert always-on-top for overlay on Windows

On Windows, the recording/transcribing overlay can lose its topmost
Z-order and fall behind other windows during certain window manager
events (e.g., dragging windows, alt-tabbing, or other apps asserting
focus).

This fix uses the Win32 SetWindowPos API directly with HWND_TOPMOST
to aggressively re-assert the overlay's Z-order immediately after
showing it. Key implementation details:

- Uses Tauri 2's WebviewWindow::hwnd() to get the native handle
- Calls SetWindowPos on the main UI thread via run_on_main_thread
- Applies flags: SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW
  - SWP_NOACTIVATE prevents stealing focus from the active window
  - SWP_SHOWWINDOW reinforces the topmost state during the show

The fix is called after .show() in both show_recording_overlay and
show_transcribing_overlay, which is more reliable than setting it
before the window is visible.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* keep the fix to windows only

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: CJ Pais <cj@cjpais.com>
2025-11-28 09:55:13 +07:00

294 lines
10 KiB
Rust

use crate::settings;
use crate::settings::OverlayPosition;
use enigo::{Enigo, Mouse};
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize};
#[cfg(not(target_os = "macos"))]
use log::debug;
#[cfg(not(target_os = "macos"))]
use tauri::WebviewWindowBuilder;
#[cfg(target_os = "macos")]
use tauri::WebviewUrl;
#[cfg(target_os = "macos")]
use tauri_nspanel::{tauri_panel, CollectionBehavior, PanelBuilder, PanelLevel};
#[cfg(target_os = "macos")]
tauri_panel! {
panel!(RecordingOverlayPanel {
config: {
can_become_key_window: false,
is_floating_panel: true
}
})
}
const OVERLAY_WIDTH: f64 = 172.0;
const OVERLAY_HEIGHT: f64 = 36.0;
#[cfg(target_os = "macos")]
const OVERLAY_TOP_OFFSET: f64 = 46.0;
#[cfg(any(target_os = "windows", target_os = "linux"))]
const OVERLAY_TOP_OFFSET: f64 = 4.0;
#[cfg(target_os = "macos")]
const OVERLAY_BOTTOM_OFFSET: f64 = 15.0;
#[cfg(any(target_os = "windows", target_os = "linux"))]
const OVERLAY_BOTTOM_OFFSET: f64 = 40.0;
/// Forces a window to be topmost using Win32 API (Windows only)
/// This is more reliable than Tauri's set_always_on_top which can be overridden
#[cfg(target_os = "windows")]
fn force_overlay_topmost(overlay_window: &tauri::webview::WebviewWindow) {
use windows::Win32::UI::WindowsAndMessaging::{
SetWindowPos, HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, SWP_NOACTIVATE, SWP_SHOWWINDOW,
};
// Clone because run_on_main_thread takes 'static
let overlay_clone = overlay_window.clone();
// Make sure the Win32 call happens on the UI thread
let _ = overlay_clone.clone().run_on_main_thread(move || {
if let Ok(hwnd) = overlay_clone.hwnd() {
unsafe {
// Force Z-order: make this window topmost without changing size/pos or stealing focus
let _ = SetWindowPos(
hwnd,
Some(HWND_TOPMOST),
0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,
);
}
}
});
}
fn get_monitor_with_cursor(app_handle: &AppHandle) -> Option<tauri::Monitor> {
let enigo = Enigo::new(&Default::default());
if let Ok(enigo) = enigo {
if let Ok(mouse_location) = enigo.location() {
if let Ok(monitors) = app_handle.available_monitors() {
for monitor in monitors {
let is_within =
is_mouse_within_monitor(mouse_location, monitor.position(), monitor.size());
if is_within {
return Some(monitor);
}
}
}
}
}
app_handle.primary_monitor().ok().flatten()
}
fn is_mouse_within_monitor(
mouse_pos: (i32, i32),
monitor_pos: &PhysicalPosition<i32>,
monitor_size: &PhysicalSize<u32>,
) -> bool {
let (mouse_x, mouse_y) = mouse_pos;
let PhysicalPosition {
x: monitor_x,
y: monitor_y,
} = *monitor_pos;
let PhysicalSize {
width: monitor_width,
height: monitor_height,
} = *monitor_size;
mouse_x >= monitor_x
&& mouse_x < (monitor_x + monitor_width as i32)
&& mouse_y >= monitor_y
&& mouse_y < (monitor_y + monitor_height as i32)
}
fn calculate_overlay_position(app_handle: &AppHandle) -> Option<(f64, f64)> {
if let Some(monitor) = get_monitor_with_cursor(app_handle) {
let work_area = monitor.work_area();
let scale = monitor.scale_factor();
let work_area_width = work_area.size.width as f64 / scale;
let work_area_height = work_area.size.height as f64 / scale;
let work_area_x = work_area.position.x as f64 / scale;
let work_area_y = work_area.position.y as f64 / scale;
let settings = settings::get_settings(app_handle);
let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0;
let y = match settings.overlay_position {
OverlayPosition::Top => work_area_y + OVERLAY_TOP_OFFSET,
OverlayPosition::Bottom | OverlayPosition::None => {
// don't subtract the overlay height it puts it too far up
work_area_y + work_area_height - OVERLAY_BOTTOM_OFFSET
}
};
return Some((x, y));
}
None
}
/// Creates the recording overlay window and keeps it hidden by default
#[cfg(not(target_os = "macos"))]
pub fn create_recording_overlay(app_handle: &AppHandle) {
if let Some((x, y)) = calculate_overlay_position(app_handle) {
match WebviewWindowBuilder::new(
app_handle,
"recording_overlay",
tauri::WebviewUrl::App("src/overlay/index.html".into()),
)
.title("Recording")
.position(x, y)
.resizable(false)
.inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT)
.shadow(false)
.maximizable(false)
.minimizable(false)
.closable(false)
.accept_first_mouse(true)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.transparent(true)
.focused(false)
.visible(false)
.build()
{
Ok(_window) => {
debug!("Recording overlay window created successfully (hidden)");
}
Err(e) => {
debug!("Failed to create recording overlay window: {}", e);
}
}
}
}
/// Creates the recording overlay panel and keeps it hidden by default (macOS)
#[cfg(target_os = "macos")]
pub fn create_recording_overlay(app_handle: &AppHandle) {
if let Some((x, y)) = calculate_overlay_position(app_handle) {
// PanelBuilder creates a Tauri window then converts it to NSPanel.
// The window remains registered, so get_webview_window() still works.
match PanelBuilder::<_, RecordingOverlayPanel>::new(
app_handle,
"recording_overlay"
)
.url(WebviewUrl::App("src/overlay/index.html".into()))
.title("Recording")
.position(tauri::Position::Logical(tauri::LogicalPosition { x, y }))
.level(PanelLevel::Status)
.size(tauri::Size::Logical(tauri::LogicalSize {
width: OVERLAY_WIDTH,
height: OVERLAY_HEIGHT
}))
.has_shadow(false)
.transparent(true)
.no_activate(true)
.corner_radius(0.0)
.with_window(|w| {
w.decorations(false)
.transparent(true)
})
.collection_behavior(
CollectionBehavior::new()
.can_join_all_spaces()
.full_screen_auxiliary()
)
.build()
{
Ok(panel) => {
let _ = panel.hide();
}
Err(e) => {
log::error!("Failed to create recording overlay panel: {}", e);
}
}
}
}
/// Shows the recording overlay window with fade-in animation
pub fn show_recording_overlay(app_handle: &AppHandle) {
// Check if overlay should be shown based on position setting
let settings = settings::get_settings(app_handle);
if settings.overlay_position == OverlayPosition::None {
return;
}
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
// Update position before showing to prevent flicker from position changes
if let Some((x, y)) = calculate_overlay_position(app_handle) {
let _ = overlay_window.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }));
}
let _ = overlay_window.show();
// On Windows, aggressively re-assert "topmost" in the native Z-order after showing
#[cfg(target_os = "windows")]
force_overlay_topmost(&overlay_window);
// Emit event to trigger fade-in animation with recording state
let _ = overlay_window.emit("show-overlay", "recording");
}
}
/// Shows the transcribing overlay window
pub fn show_transcribing_overlay(app_handle: &AppHandle) {
// Check if overlay should be shown based on position setting
let settings = settings::get_settings(app_handle);
if settings.overlay_position == OverlayPosition::None {
return;
}
update_overlay_position(app_handle);
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
let _ = overlay_window.show();
// On Windows, aggressively re-assert "topmost" in the native Z-order after showing
#[cfg(target_os = "windows")]
force_overlay_topmost(&overlay_window);
// Emit event to switch to transcribing state
let _ = overlay_window.emit("show-overlay", "transcribing");
}
}
/// Updates the overlay window position based on current settings
pub fn update_overlay_position(app_handle: &AppHandle) {
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
if let Some((x, y)) = calculate_overlay_position(app_handle) {
let _ = overlay_window
.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }));
}
}
}
/// Hides the recording overlay window with fade-out animation
pub fn hide_recording_overlay(app_handle: &AppHandle) {
// Always hide the overlay regardless of settings - if setting was changed while recording,
// we still want to hide it properly
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
// Emit event to trigger fade-out animation
let _ = overlay_window.emit("hide-overlay", ());
// Hide the window after a short delay to allow animation to complete
let window_clone = overlay_window.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(300));
let _ = window_clone.hide();
});
}
}
pub fn emit_levels(app_handle: &AppHandle, levels: &Vec<f32>) {
// emit levels to main app
let _ = app_handle.emit("mic-level", levels);
// also emit to the recording overlay if it's open
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
let _ = overlay_window.emit("mic-level", levels);
}
}