893 lines
35 KiB
Rust
893 lines
35 KiB
Rust
use crate::input;
|
|
use crate::settings;
|
|
use crate::settings::{OverlayPosition, OverlayStyle};
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
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, StyleMask};
|
|
|
|
#[cfg(target_os = "linux")]
|
|
use crate::utils;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
use gtk_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
|
|
|
#[cfg(target_os = "macos")]
|
|
tauri_panel! {
|
|
panel!(RecordingOverlayPanel {
|
|
config: {
|
|
can_become_key_window: false,
|
|
is_floating_panel: true
|
|
}
|
|
})
|
|
}
|
|
|
|
// Native overlay window sizes (logical points). One window is reused for every
|
|
// state and resized in `show_overlay_state`; each size need only be at least as
|
|
// large as the card it hosts (the `--ov-*` vars in RecordingOverlay.css). The
|
|
// card is CSS-anchored flush to the screen edge, so window height doesn't move
|
|
// where the card sits — only OVERLAY_TOP_OFFSET / OVERLAY_BOTTOM_OFFSET do. Keep
|
|
// these in sync with the CSS card geometry.
|
|
//
|
|
// On Windows these sizes are additionally multiplied by the accessibility text
|
|
// scale (see windows_text_scale_factor), which WebView2 applies as a zoom.
|
|
//
|
|
// Compact overlay (Minimal / transcribing / processing): the 40h pill animates
|
|
// width from 172 (--ov-rest-w) to 216 (--ov-work-w) and expands from center, so
|
|
// the window must fit the widest state plus a little slack.
|
|
const OVERLAY_WIDTH: f64 = 256.0;
|
|
const OVERLAY_HEIGHT: f64 = 50.0;
|
|
|
|
// Actual is 394x118, just a little extra
|
|
const OVERLAY_STREAM_WIDTH: f64 = 400.0;
|
|
const OVERLAY_STREAM_HEIGHT: f64 = 120.0;
|
|
|
|
/// Overlay window size (logical) for a given UI state.
|
|
fn overlay_dimensions(state: &str) -> (f64, f64) {
|
|
if state == "streaming" {
|
|
(OVERLAY_STREAM_WIDTH, OVERLAY_STREAM_HEIGHT)
|
|
} else {
|
|
(OVERLAY_WIDTH, OVERLAY_HEIGHT)
|
|
}
|
|
}
|
|
|
|
static LAST_MIC_LEVEL_EMIT: AtomicU64 = AtomicU64::new(0);
|
|
const EMIT_THROTTLE_MS: u64 = 33; // ~30 FPS
|
|
|
|
#[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;
|
|
|
|
/// Configures the edge and offset of a GTK layer surface. gtk-layer-shell
|
|
/// commits anchor and margin changes itself, including while the surface is
|
|
/// mapped, so changing position does not require a manual hide/show cycle.
|
|
#[cfg(target_os = "linux")]
|
|
fn configure_layer_shell_position(gtk_window: >k::ApplicationWindow, position: OverlayPosition) {
|
|
let (edge, opposite_edge, margin) = match position {
|
|
OverlayPosition::Top => (Edge::Top, Edge::Bottom, OVERLAY_TOP_OFFSET),
|
|
OverlayPosition::Bottom => (Edge::Bottom, Edge::Top, OVERLAY_BOTTOM_OFFSET),
|
|
};
|
|
|
|
gtk_window.set_anchor(edge, true);
|
|
gtk_window.set_anchor(opposite_edge, false);
|
|
gtk_window.set_layer_shell_margin(edge, margin.round() as i32);
|
|
gtk_window.set_layer_shell_margin(opposite_edge, 0);
|
|
}
|
|
|
|
/// Configures a GTK layer surface before it is shown.
|
|
///
|
|
/// Tauri's normal `set_size` path calls `gtk_window_resize`, but layer surfaces
|
|
/// derive their dimensions from GTK's size request. gtk-layer-shell documents
|
|
/// the `set_size_request` + `resize(1, 1)` sequence for forcing a new size.
|
|
#[cfg(target_os = "linux")]
|
|
fn configure_layer_shell_surface(
|
|
gtk_window: >k::ApplicationWindow,
|
|
position: OverlayPosition,
|
|
width: f64,
|
|
height: f64,
|
|
) {
|
|
use gtk::prelude::{GtkWindowExt, WidgetExt};
|
|
|
|
configure_layer_shell_position(gtk_window, position);
|
|
|
|
gtk_window.set_size_request(
|
|
width.round().max(1.0) as i32,
|
|
height.round().max(1.0) as i32,
|
|
);
|
|
gtk_window.resize(1, 1);
|
|
}
|
|
|
|
/// 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 {
|
|
if utils::env_flag_enabled("HANDY_NO_GTK_LAYER_SHELL") {
|
|
debug!("Skipping GTK layer shell init (HANDY_NO_GTK_LAYER_SHELL is enabled)");
|
|
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() {
|
|
gtk_window.init_layer_shell();
|
|
gtk_window.set_layer(Layer::Overlay);
|
|
gtk_window.set_keyboard_mode(KeyboardMode::None);
|
|
gtk_window.set_exclusive_zone(0);
|
|
|
|
let overlay_position = settings::get_settings(overlay_window.app_handle()).overlay_position;
|
|
configure_layer_shell_surface(>k_window, overlay_position, OVERLAY_WIDTH, OVERLAY_HEIGHT);
|
|
|
|
let initialized = gtk_window.is_layer_window();
|
|
LAYER_SHELL_ACTIVE.store(initialized, Ordering::SeqCst);
|
|
return initialized;
|
|
}
|
|
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 {
|
|
// On Windows both the cursor (enigo -> GetCursorPos) and the
|
|
// monitor bounds are physical pixels, so compare them directly.
|
|
#[cfg(target_os = "windows")]
|
|
if is_mouse_within_monitor(mouse_location, monitor.position(), monitor.size()) {
|
|
return Some(monitor);
|
|
}
|
|
|
|
// macOS/Linux: enigo returns logical coords, so scale the bounds down.
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
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).
|
|
///
|
|
/// The Bottom anchor uses the macOS work area (visibleFrame) so the overlay
|
|
/// tracks the Dock — above it when shown, at the screen edge when hidden.
|
|
/// This relies on tauri 2.11's work_area.position.y fix (#14655), the same
|
|
/// bug that led PR #969 to abandon work_area for full monitor bounds. Top and
|
|
/// the other platforms keep full monitor bounds plus the fixed offsets
|
|
/// (work_area is unreliable on Wayland; Windows' offset clears the 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. Windows uses
|
|
/// `place_windows_overlay` instead (no single logical space across mixed DPI).
|
|
fn calculate_overlay_position(
|
|
app_handle: &AppHandle,
|
|
width: f64,
|
|
height: f64,
|
|
) -> 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 settings = settings::get_settings(app_handle);
|
|
|
|
let x = monitor_x + (monitor_width - width) / 2.0;
|
|
let y = match settings.overlay_position {
|
|
OverlayPosition::Top => monitor_y + OVERLAY_TOP_OFFSET,
|
|
OverlayPosition::Bottom => {
|
|
// work_area.position shares monitor.position's global coordinate
|
|
// space, so no monitor offset is added.
|
|
#[cfg(target_os = "macos")]
|
|
let bottom = {
|
|
let wa = monitor.work_area();
|
|
(wa.position.y as f64 + wa.size.height as f64) / scale
|
|
};
|
|
#[cfg(not(target_os = "macos"))]
|
|
let bottom = monitor_y + monitor.size().height as f64 / scale;
|
|
|
|
bottom - height - OVERLAY_BOTTOM_OFFSET
|
|
}
|
|
};
|
|
|
|
Some((x, y))
|
|
}
|
|
|
|
/// Current overlay window size in logical units (points), for repositioning
|
|
/// without assuming a fixed size (compact vs. streaming).
|
|
#[cfg(not(target_os = "windows"))]
|
|
fn current_overlay_logical_size(window: &tauri::webview::WebviewWindow) -> Option<(f64, f64)> {
|
|
let size = window.inner_size().ok()?;
|
|
let scale = window.scale_factor().ok()?;
|
|
Some((size.width as f64 / scale, size.height as f64 / scale))
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
static WINDOWS_OVERLAY_IS_STREAMING: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Windows accessibility text size (Settings > Accessibility > Text size), a
|
|
/// separate axis from display scaling that WebView2 applies as a document zoom.
|
|
#[cfg(target_os = "windows")]
|
|
fn windows_text_scale_factor() -> f64 {
|
|
// Absent until the user moves the slider off 100%; stored as a percentage.
|
|
winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER)
|
|
.open_subkey(r"Software\Microsoft\Accessibility")
|
|
.and_then(|key| key.get_value::<u32, _>("TextScaleFactor"))
|
|
.map(|percent| (percent as f64 / 100.0).clamp(1.0, 2.25))
|
|
.unwrap_or(1.0)
|
|
}
|
|
|
|
/// Overlay rectangle in the destination monitor's physical pixels, so nothing
|
|
/// is converted through the window's previous-monitor DPI.
|
|
#[cfg(target_os = "windows")]
|
|
fn windows_overlay_bounds(
|
|
monitor_position: PhysicalPosition<i32>,
|
|
monitor_size: PhysicalSize<u32>,
|
|
scale: f64,
|
|
text_scale: f64,
|
|
logical_width: f64,
|
|
logical_height: f64,
|
|
overlay_position: OverlayPosition,
|
|
) -> (i32, i32, i32, i32) {
|
|
// Grow the window with the text scale; offsets stay DPI-only since the
|
|
// card sits flush against the window's screen-edge side.
|
|
let content_scale = scale * text_scale;
|
|
let width = (logical_width * content_scale).round().max(1.0) as i32;
|
|
let height = (logical_height * content_scale).round().max(1.0) as i32;
|
|
let x = (monitor_position.x as f64 + (monitor_size.width as f64 - width as f64) / 2.0).round()
|
|
as i32;
|
|
let y = match overlay_position {
|
|
OverlayPosition::Top => {
|
|
(monitor_position.y as f64 + OVERLAY_TOP_OFFSET * scale).round() as i32
|
|
}
|
|
OverlayPosition::Bottom => (monitor_position.y as f64 + monitor_size.height as f64
|
|
- height as f64
|
|
- OVERLAY_BOTTOM_OFFSET * scale)
|
|
.round() as i32,
|
|
};
|
|
|
|
(x, y, width, height)
|
|
}
|
|
|
|
/// Moves and sizes the overlay in one native SetWindowPos, bypassing tao's
|
|
/// current-DPI logical conversion that mislands cross-monitor moves.
|
|
#[cfg(target_os = "windows")]
|
|
fn place_windows_overlay(
|
|
app_handle: &AppHandle,
|
|
overlay_window: &tauri::webview::WebviewWindow,
|
|
logical_width: f64,
|
|
logical_height: f64,
|
|
) -> Result<(), String> {
|
|
use windows::Win32::UI::WindowsAndMessaging::{SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER};
|
|
|
|
let monitor = get_monitor_with_cursor(app_handle)
|
|
.ok_or_else(|| "failed to determine the monitor containing the cursor".to_string())?;
|
|
let text_scale = windows_text_scale_factor();
|
|
let (x, y, width, height) = windows_overlay_bounds(
|
|
*monitor.position(),
|
|
*monitor.size(),
|
|
monitor.scale_factor(),
|
|
text_scale,
|
|
logical_width,
|
|
logical_height,
|
|
settings::get_settings(app_handle).overlay_position,
|
|
);
|
|
let hwnd = overlay_window
|
|
.hwnd()
|
|
.map_err(|error| format!("failed to get overlay window handle: {error}"))?;
|
|
|
|
unsafe {
|
|
SetWindowPos(
|
|
hwnd,
|
|
None,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
SWP_NOACTIVATE | SWP_NOZORDER,
|
|
)
|
|
.map_err(|error| format!("failed to set overlay bounds: {error}"))?;
|
|
}
|
|
|
|
log::debug!(
|
|
"windows overlay bounds: x={} y={} width={} height={} scale={} text_scale={}",
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
monitor.scale_factor(),
|
|
text_scale
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 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, OVERLAY_WIDTH, OVERLAY_HEIGHT);
|
|
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)
|
|
.focusable(false)
|
|
.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, OVERLAY_WIDTH, OVERLAY_HEIGHT) {
|
|
// 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)
|
|
.style_mask(StyleMask::empty().borderless().nonactivating_panel())
|
|
.with_window(|w| w.decorations(false).transparent(true).focusable(false))
|
|
.collection_behavior(
|
|
CollectionBehavior::new()
|
|
.can_join_all_spaces()
|
|
.full_screen_auxiliary(),
|
|
)
|
|
.build()
|
|
{
|
|
Ok(panel) => {
|
|
panel.hide();
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to create recording overlay panel: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn show_overlay_state(app_handle: &AppHandle, state: &str) {
|
|
// Whether the overlay shows at all is governed by overlay_style; position
|
|
// only chooses Top vs Bottom placement. Checked here (off the main thread)
|
|
// so the common overlay-disabled case never pays for a main-thread hop.
|
|
let settings = settings::get_settings(app_handle);
|
|
if settings.overlay_style == OverlayStyle::None {
|
|
return;
|
|
}
|
|
|
|
// The rest queries monitors and the cursor and mutates window geometry. On
|
|
// Linux the monitor/cursor lookups hit GDK/Xlib on the process's shared X11
|
|
// connection, which is only safe from the GTK main thread — running them on
|
|
// a background thread corrupts the connection and hard-crashes the app
|
|
// (issue #227). Hop to the main thread on every platform to keep the
|
|
// geometry path uniform (a no-op cost on Windows, and it also keeps macOS's
|
|
// NSScreen access main-thread-correct). run_on_main_thread runs the closure
|
|
// inline when already on the main thread, so this never deadlocks.
|
|
let handle = app_handle.clone();
|
|
let state = state.to_string();
|
|
let _ = app_handle.run_on_main_thread(move || show_overlay_state_on_main(&handle, &state));
|
|
}
|
|
|
|
fn show_overlay_state_on_main(app_handle: &AppHandle, state: &str) {
|
|
// Size the overlay for this state (compact vs. streaming), then position it.
|
|
let (width, height) = overlay_dimensions(state);
|
|
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
|
|
// Invalidate any delayed hide still in flight from a previous session
|
|
// (see `hide_recording_overlay`).
|
|
OVERLAY_SHOW_GENERATION.fetch_add(1, Ordering::SeqCst);
|
|
|
|
#[cfg(target_os = "linux")]
|
|
let shown_with_layer_shell = if LAYER_SHELL_ACTIVE.load(Ordering::SeqCst) {
|
|
let position = settings::get_settings(app_handle).overlay_position;
|
|
match overlay_window.gtk_window() {
|
|
Ok(gtk_window) => {
|
|
configure_layer_shell_surface(>k_window, position, width, height)
|
|
}
|
|
Err(error) => log::error!("Failed to access GTK overlay window: {error}"),
|
|
}
|
|
let _ = overlay_window.show();
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
#[cfg(not(target_os = "linux"))]
|
|
let shown_with_layer_shell = false;
|
|
|
|
if !shown_with_layer_shell {
|
|
let size_started = std::time::Instant::now();
|
|
#[cfg(not(target_os = "windows"))]
|
|
let _ =
|
|
overlay_window.set_size(tauri::Size::Logical(tauri::LogicalSize { width, height }));
|
|
#[cfg(target_os = "windows")]
|
|
WINDOWS_OVERLAY_IS_STREAMING.store(state == "streaming", Ordering::Relaxed);
|
|
let size_elapsed = size_started.elapsed();
|
|
|
|
let pos_started = std::time::Instant::now();
|
|
#[cfg(not(target_os = "windows"))]
|
|
let set_pos_elapsed =
|
|
if let Some((x, y)) = calculate_overlay_position(app_handle, width, height) {
|
|
let set_pos_started = std::time::Instant::now();
|
|
let _ = overlay_window
|
|
.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }));
|
|
set_pos_started.elapsed()
|
|
} else {
|
|
std::time::Duration::ZERO
|
|
};
|
|
#[cfg(target_os = "windows")]
|
|
let set_pos_elapsed = {
|
|
let set_pos_started = std::time::Instant::now();
|
|
if let Err(error) =
|
|
place_windows_overlay(app_handle, &overlay_window, width, height)
|
|
{
|
|
log::error!("Failed to place recording overlay: {error}");
|
|
}
|
|
set_pos_started.elapsed()
|
|
};
|
|
let pos_calc_elapsed = pos_started.elapsed() - set_pos_elapsed;
|
|
|
|
let show_started = std::time::Instant::now();
|
|
let _ = overlay_window.show();
|
|
let show_elapsed = show_started.elapsed();
|
|
|
|
// On Windows, aggressively re-assert "topmost" in the native Z-order after showing
|
|
#[cfg(target_os = "windows")]
|
|
force_overlay_topmost(&overlay_window);
|
|
|
|
// Re-assert bounds after show(): the pre-show move crosses the DPI
|
|
// boundary, and tao's WM_DPICHANGED reflow clobbers the first placement.
|
|
#[cfg(target_os = "windows")]
|
|
if let Err(error) = place_windows_overlay(app_handle, &overlay_window, width, height) {
|
|
log::error!("Failed to re-assert recording overlay position: {error}");
|
|
}
|
|
|
|
log::debug!(
|
|
"overlay '{}': set_size={:?} pos_calc={:?} set_pos={:?} show={:?}",
|
|
state,
|
|
size_elapsed,
|
|
pos_calc_elapsed,
|
|
set_pos_elapsed,
|
|
show_elapsed
|
|
);
|
|
}
|
|
|
|
let _ = overlay_window.emit("show-overlay", state);
|
|
}
|
|
}
|
|
|
|
/// Notify the visible recording overlay that the input stream has delivered its
|
|
/// first sample chunk. Audio feedback uses the same backend readiness signal,
|
|
/// but this targeted event is skipped when overlays are disabled.
|
|
pub fn emit_recording_ready(app_handle: &AppHandle) {
|
|
if !OVERLAY_ENABLED.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
|
|
// Showing the overlay is also queued onto the main thread. Queue readiness
|
|
// there as well so a very fast always-on stream cannot overtake show-overlay
|
|
// and then get reset back to the arming state by the frontend.
|
|
let handle = app_handle.clone();
|
|
let _ = app_handle.run_on_main_thread(move || {
|
|
let _ = handle.emit_to("recording_overlay", "recording-ready", ());
|
|
});
|
|
}
|
|
|
|
/// 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 larger streaming overlay that displays live transcription text
|
|
pub fn show_streaming_overlay(app_handle: &AppHandle) {
|
|
show_overlay_state(app_handle, "streaming");
|
|
}
|
|
|
|
/// 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) {
|
|
// Positioning queries monitors/cursor (GDK/Xlib on Linux) and moves the
|
|
// window, so it must run on the main thread — see show_overlay_state.
|
|
let handle = app_handle.clone();
|
|
let _ = app_handle.run_on_main_thread(move || update_overlay_position_on_main(&handle));
|
|
}
|
|
|
|
fn update_overlay_position_on_main(app_handle: &AppHandle) {
|
|
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
|
|
#[cfg(target_os = "linux")]
|
|
if LAYER_SHELL_ACTIVE.load(Ordering::SeqCst) {
|
|
let position = settings::get_settings(app_handle).overlay_position;
|
|
match overlay_window.gtk_window() {
|
|
Ok(gtk_window) => configure_layer_shell_position(>k_window, position),
|
|
Err(error) => log::error!("Failed to access GTK overlay window: {error}"),
|
|
}
|
|
return;
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let state = if WINDOWS_OVERLAY_IS_STREAMING.load(Ordering::Relaxed) {
|
|
"streaming"
|
|
} else {
|
|
"recording"
|
|
};
|
|
let (width, height) = overlay_dimensions(state);
|
|
if let Err(error) = place_windows_overlay(app_handle, &overlay_window, width, height) {
|
|
log::error!("Failed to update recording overlay position: {error}");
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
// Use the window's current size so centering stays correct whether the
|
|
// overlay is in compact or streaming layout.
|
|
let (width, height) = current_overlay_logical_size(&overlay_window)
|
|
.unwrap_or((OVERLAY_WIDTH, OVERLAY_HEIGHT));
|
|
if let Some((x, y)) = calculate_overlay_position(app_handle, width, height) {
|
|
let _ = overlay_window
|
|
.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generation counter bumped every time the overlay is shown. The delayed
|
|
/// `hide()` below only unmaps the window if no show happened after it was
|
|
/// scheduled, so a hide left over from a finished transcription can never
|
|
/// take down the overlay of a session that started in the meantime — e.g. a
|
|
/// press the coordinator remembered while the pipeline was busy and started
|
|
/// the instant it drained, well inside the 300 ms hide delay.
|
|
static OVERLAY_SHOW_GENERATION: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// 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") {
|
|
// Snapshot before doing anything observable, so any show that lands
|
|
// after this point invalidates the delayed hide below.
|
|
let scheduled_at = OVERLAY_SHOW_GENERATION.load(Ordering::SeqCst);
|
|
// 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,
|
|
// unless a newer session has shown the overlay again by then.
|
|
let window_clone = overlay_window.clone();
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
|
if OVERLAY_SHOW_GENERATION.load(Ordering::SeqCst) != scheduled_at {
|
|
log::debug!("Skipping stale overlay hide: a newer session is showing the overlay");
|
|
return;
|
|
}
|
|
let _ = window_clone.hide();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Cached "overlay is enabled" flag, kept in sync with overlay_style. Avoids
|
|
// reading the Tauri store on every audio callback (~24 Hz during recording).
|
|
// Defaults to false so the audio path doesn't emit until lib.rs::setup
|
|
// populates the cache from initial settings.
|
|
static OVERLAY_ENABLED: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Tracks whether gtk-layer-shell was successfully initialized (Linux only).
|
|
/// Used to skip layer-shell calls when the window is a regular fallback.
|
|
#[cfg(target_os = "linux")]
|
|
static LAYER_SHELL_ACTIVE: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Update the cached overlay-enabled flag. Called from `lib.rs` at
|
|
/// startup after settings load, and from `change_overlay_style_setting`
|
|
/// whenever the user changes whether the overlay is shown.
|
|
pub fn update_overlay_enabled_cache(enabled: bool) {
|
|
OVERLAY_ENABLED.store(enabled, Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn emit_levels(app_handle: &AppHandle, levels: &[f32]) {
|
|
// Skip emission when the overlay is disabled. The recording_overlay
|
|
// window is created at boot regardless of overlay_style, so without this
|
|
// guard a hidden overlay's WebKit subprocess still
|
|
// processes every event. Each event drives some kind of WebKit
|
|
// C++ allocation that accumulates without bound (mechanism not
|
|
// directly characterized; see issue #1279 for the investigation).
|
|
// For users with `overlay_style: none` (the Linux default) this skip
|
|
// eliminates the upstream driver of that accumulation.
|
|
if !OVERLAY_ENABLED.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
|
|
// Throttle to ~30 FPS. Even with the overlay enabled, the raw audio
|
|
// callback fires far faster than the UI needs; capping emission rate
|
|
// cuts the per-frame `eval_script`/IPC volume that drives the wry
|
|
// memory growth in issue #1279 (upstream tauri-apps/wry#1489).
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64;
|
|
let last = LAST_MIC_LEVEL_EMIT.load(Ordering::Relaxed);
|
|
if now.saturating_sub(last) < EMIT_THROTTLE_MS {
|
|
return;
|
|
}
|
|
LAST_MIC_LEVEL_EMIT.store(now, Ordering::Relaxed);
|
|
|
|
// Target only the overlay window. In Tauri 2 both `AppHandle::emit`
|
|
// and `WebviewWindow::emit` broadcast to all webviews; Tauri's
|
|
// listener filter then skips webviews with no registered listener
|
|
// for the event, so the settings webview never received `mic-level`.
|
|
// But the previous dual-call pattern still produced two `eval_script`
|
|
// calls to the overlay per audio callback (one from each .emit()).
|
|
// `emit_to` with the overlay's window label produces a single
|
|
// eval_script call per callback, cutting the per-callback WebKit
|
|
// dispatch work in half.
|
|
let _ = app_handle.emit_to("recording_overlay", "mic-level", levels);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn monitor_hit_test_uses_half_open_physical_bounds() {
|
|
let position = PhysicalPosition::new(-2560, -200);
|
|
let size = PhysicalSize::new(2560, 1440);
|
|
|
|
assert!(is_mouse_within_monitor((-2560, -200), &position, &size));
|
|
assert!(is_mouse_within_monitor((-1, 1239), &position, &size));
|
|
assert!(!is_mouse_within_monitor((0, 0), &position, &size));
|
|
assert!(!is_mouse_within_monitor((-1, 1240), &position, &size));
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn windows_cursor_hit_test_does_not_scale_physical_monitor_bounds() {
|
|
let position = PhysicalPosition::new(1920, 0);
|
|
let size = PhysicalSize::new(3840, 2160);
|
|
let cursor = (5000, 1000);
|
|
|
|
assert!(is_mouse_within_monitor(cursor, &position, &size));
|
|
|
|
// This is the old mixed-coordinate comparison. It excludes a cursor
|
|
// that is visibly inside a secondary display running at 150%.
|
|
let scale = 1.5;
|
|
let logical_position = PhysicalPosition::new(
|
|
(position.x as f64 / scale) as i32,
|
|
(position.y as f64 / scale) as i32,
|
|
);
|
|
let logical_size = PhysicalSize::new(
|
|
(size.width as f64 / scale) as u32,
|
|
(size.height as f64 / scale) as u32,
|
|
);
|
|
assert!(!is_mouse_within_monitor(
|
|
cursor,
|
|
&logical_position,
|
|
&logical_size
|
|
));
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn windows_overlay_bounds_use_destination_monitor_scale() {
|
|
let monitor_position = PhysicalPosition::new(1920, 0);
|
|
let monitor_size = PhysicalSize::new(3840, 2160);
|
|
|
|
assert_eq!(
|
|
windows_overlay_bounds(
|
|
monitor_position,
|
|
monitor_size,
|
|
1.5,
|
|
1.0,
|
|
OVERLAY_WIDTH,
|
|
OVERLAY_HEIGHT,
|
|
OverlayPosition::Bottom,
|
|
),
|
|
(3648, 2025, 384, 75)
|
|
);
|
|
assert_eq!(
|
|
windows_overlay_bounds(
|
|
monitor_position,
|
|
monitor_size,
|
|
1.5,
|
|
1.0,
|
|
OVERLAY_WIDTH,
|
|
OVERLAY_HEIGHT,
|
|
OverlayPosition::Top,
|
|
),
|
|
(3648, 6, 384, 75)
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn windows_overlay_bounds_support_negative_monitor_origins() {
|
|
assert_eq!(
|
|
windows_overlay_bounds(
|
|
PhysicalPosition::new(-2560, -200),
|
|
PhysicalSize::new(2560, 1440),
|
|
1.25,
|
|
1.0,
|
|
OVERLAY_STREAM_WIDTH,
|
|
OVERLAY_STREAM_HEIGHT,
|
|
OverlayPosition::Bottom,
|
|
),
|
|
(-1530, 1040, 500, 150)
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn windows_overlay_bounds_grow_with_text_scale_without_moving_the_anchored_edge() {
|
|
let monitor_position = PhysicalPosition::new(-2560, -200);
|
|
let monitor_size = PhysicalSize::new(2560, 1440);
|
|
|
|
let (x, y, width, height) = windows_overlay_bounds(
|
|
monitor_position,
|
|
monitor_size,
|
|
1.25,
|
|
1.1,
|
|
OVERLAY_STREAM_WIDTH,
|
|
OVERLAY_STREAM_HEIGHT,
|
|
OverlayPosition::Bottom,
|
|
);
|
|
// 400x120 logical at 1.25 DPI x 1.1 text, still centered horizontally.
|
|
assert_eq!((x, y, width, height), (-1555, 1025, 550, 165));
|
|
// Bottom edge unchanged from the 1.0 case above (1040 + 150).
|
|
assert_eq!(y + height, 1190);
|
|
|
|
let (_, top_y, _, _) = windows_overlay_bounds(
|
|
monitor_position,
|
|
monitor_size,
|
|
1.25,
|
|
1.1,
|
|
OVERLAY_STREAM_WIDTH,
|
|
OVERLAY_STREAM_HEIGHT,
|
|
OverlayPosition::Top,
|
|
);
|
|
// Top offset rides the DPI scale alone, so the top edge doesn't move.
|
|
assert_eq!(top_y, -195);
|
|
}
|
|
}
|