390 lines
14 KiB
Rust
390 lines
14 KiB
Rust
use crate::input;
|
|
use crate::settings;
|
|
use crate::settings::OverlayPosition;
|
|
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 = "linux")]
|
|
use gtk_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
|
#[cfg(target_os = "linux")]
|
|
use std::env;
|
|
|
|
#[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;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn update_gtk_layer_shell_anchors(overlay_window: &tauri::webview::WebviewWindow) {
|
|
let window_clone = overlay_window.clone();
|
|
let _ = overlay_window.run_on_main_thread(move || {
|
|
// Try to get the GTK window from the Tauri webview
|
|
if let Ok(gtk_window) = window_clone.gtk_window() {
|
|
let settings = settings::get_settings(window_clone.app_handle());
|
|
match settings.overlay_position {
|
|
OverlayPosition::Top => {
|
|
gtk_window.set_anchor(Edge::Top, true);
|
|
gtk_window.set_anchor(Edge::Bottom, false);
|
|
}
|
|
OverlayPosition::Bottom | OverlayPosition::None => {
|
|
gtk_window.set_anchor(Edge::Bottom, true);
|
|
gtk_window.set_anchor(Edge::Top, false);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Initializes GTK layer shell for Linux overlay window
|
|
/// Returns true if layer shell was successfully initialized, false otherwise
|
|
#[cfg(target_os = "linux")]
|
|
fn init_gtk_layer_shell(overlay_window: &tauri::webview::WebviewWindow) -> bool {
|
|
// On KDE Wayland, layer-shell init has shown protocol instability.
|
|
// Fall back to regular always-on-top overlay behavior (as in v0.7.1).
|
|
let is_wayland = env::var("WAYLAND_DISPLAY").is_ok()
|
|
|| env::var("XDG_SESSION_TYPE")
|
|
.map(|v| v.eq_ignore_ascii_case("wayland"))
|
|
.unwrap_or(false);
|
|
let is_kde = env::var("XDG_CURRENT_DESKTOP")
|
|
.map(|v| v.to_uppercase().contains("KDE"))
|
|
.unwrap_or(false)
|
|
|| env::var("KDE_SESSION_VERSION").is_ok();
|
|
if is_wayland && is_kde {
|
|
debug!("Skipping GTK layer shell init on KDE Wayland");
|
|
return false;
|
|
}
|
|
|
|
if !gtk_layer_shell::is_supported() {
|
|
return false;
|
|
}
|
|
|
|
// Try to get the GTK window from the Tauri webview
|
|
if let Ok(gtk_window) = overlay_window.gtk_window() {
|
|
// Initialize layer shell
|
|
gtk_window.init_layer_shell();
|
|
gtk_window.set_layer(Layer::Overlay);
|
|
gtk_window.set_keyboard_mode(KeyboardMode::None);
|
|
gtk_window.set_exclusive_zone(0);
|
|
|
|
update_gtk_layer_shell_anchors(overlay_window);
|
|
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// 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_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, 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> {
|
|
if let Some(mouse_location) = input::get_cursor_position(app_handle) {
|
|
if let Ok(monitors) = app_handle.available_monitors() {
|
|
for monitor in monitors {
|
|
// Tauri's monitor position/size are physical pixels, but enigo
|
|
// may return logical coordinates (confirmed on macOS via
|
|
// NSEvent::mouseLocation; on Windows, GetCursorPos behavior
|
|
// depends on the process DPI-awareness context). Dividing by
|
|
// scale_factor normalizes to logical, which is safe regardless:
|
|
// if enigo returns logical it matches directly, and if it returns
|
|
// physical on a scale=1 monitor the division is a no-op.
|
|
let scale = monitor.scale_factor();
|
|
let pos = PhysicalPosition::new(
|
|
(monitor.position().x as f64 / scale) as i32,
|
|
(monitor.position().y as f64 / scale) as i32,
|
|
);
|
|
let size = PhysicalSize::new(
|
|
(monitor.size().width as f64 / scale) as u32,
|
|
(monitor.size().height as f64 / scale) as u32,
|
|
);
|
|
if is_mouse_within_monitor(mouse_location, &pos, &size) {
|
|
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)
|
|
}
|
|
|
|
/// Returns overlay position in logical coordinates (points on macOS).
|
|
///
|
|
/// Uses monitor position/size directly rather than work_area(), which can
|
|
/// return incorrect coordinates on macOS for monitors with negative positions.
|
|
/// The per-platform OVERLAY_TOP_OFFSET / OVERLAY_BOTTOM_OFFSET constants
|
|
/// already account for system chrome (menu bar, taskbar).
|
|
///
|
|
/// We must use LogicalPosition (not PhysicalPosition) because Tauri/tao
|
|
/// converts PhysicalPosition using the scale factor of the monitor the window
|
|
/// is *currently* on, which is wrong when moving cross-monitor.
|
|
fn calculate_overlay_position(app_handle: &AppHandle) -> Option<(f64, f64)> {
|
|
let monitor = get_monitor_with_cursor(app_handle)?;
|
|
let scale = monitor.scale_factor();
|
|
let monitor_x = monitor.position().x as f64 / scale;
|
|
let monitor_y = monitor.position().y as f64 / scale;
|
|
let monitor_width = monitor.size().width as f64 / scale;
|
|
let monitor_height = monitor.size().height as f64 / scale;
|
|
|
|
let settings = settings::get_settings(app_handle);
|
|
|
|
let x = monitor_x + (monitor_width - OVERLAY_WIDTH) / 2.0;
|
|
let y = match settings.overlay_position {
|
|
OverlayPosition::Top => monitor_y + OVERLAY_TOP_OFFSET,
|
|
OverlayPosition::Bottom | OverlayPosition::None => {
|
|
monitor_y + monitor_height - OVERLAY_HEIGHT - OVERLAY_BOTTOM_OFFSET
|
|
}
|
|
};
|
|
|
|
Some((x, y))
|
|
}
|
|
|
|
/// Creates the recording overlay window and keeps it hidden by default
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub fn create_recording_overlay(app_handle: &AppHandle) {
|
|
// On Linux (Wayland), monitor detection often fails, but we don't need exact coordinates
|
|
// for Layer Shell as we use anchors. On other platforms, we require a monitor.
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
let position = calculate_overlay_position(app_handle);
|
|
if position.is_none() {
|
|
debug!("Failed to determine overlay position, not creating overlay window");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Position starts unset — update_overlay_position() sets the correct
|
|
// LogicalPosition before the overlay is shown.
|
|
let mut builder = WebviewWindowBuilder::new(
|
|
app_handle,
|
|
"recording_overlay",
|
|
tauri::WebviewUrl::App("src/overlay/index.html".into()),
|
|
)
|
|
.title("Recording")
|
|
.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);
|
|
|
|
if let Some(data_dir) = crate::portable::data_dir() {
|
|
builder = builder.data_directory(data_dir.join("webview"));
|
|
}
|
|
|
|
#[allow(unused_variables)]
|
|
match builder.build() {
|
|
Ok(window) => {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
// Try to initialize GTK layer shell, ignore errors if compositor doesn't support it
|
|
if init_gtk_layer_shell(&window) {
|
|
debug!("GTK layer shell initialized for overlay window");
|
|
} else {
|
|
debug!("GTK layer shell not available, falling back to regular 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn show_overlay_state(app_handle: &AppHandle, state: &str) {
|
|
// 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);
|
|
|
|
let _ = overlay_window.emit("show-overlay", state);
|
|
}
|
|
}
|
|
|
|
/// Shows the recording overlay window with fade-in animation
|
|
pub fn show_recording_overlay(app_handle: &AppHandle) {
|
|
show_overlay_state(app_handle, "recording");
|
|
}
|
|
|
|
/// Shows the transcribing overlay window
|
|
pub fn show_transcribing_overlay(app_handle: &AppHandle) {
|
|
show_overlay_state(app_handle, "transcribing");
|
|
}
|
|
|
|
/// Shows the processing overlay window
|
|
pub fn show_processing_overlay(app_handle: &AppHandle) {
|
|
show_overlay_state(app_handle, "processing");
|
|
}
|
|
|
|
/// 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") {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
update_gtk_layer_shell_anchors(&overlay_window);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|