forked from cjpais/Handy
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoverlay.rs
More file actions
388 lines (341 loc) · 14 KB
/
overlay.rs
File metadata and controls
388 lines (341 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
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) {
let position = calculate_overlay_position(app_handle);
// 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"))]
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"));
}
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);
}
}