From d114de5a68752f7f6b9a05452c46651a2d7461ed Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 7 Apr 2026 13:55:59 +0700 Subject: [PATCH 1/3] fix(panel): position panel under tray icon on all entry paths without flicker The global shortcut and tray menu actions (Show Stats, Settings, About) never called position_panel_at_tray_icon, so the panel appeared at macOS's default window position on first open. Additionally, the existing positioning used tao's async set_position (exec_async), causing a visible flicker when show_and_make_key fired before the queued move was applied. Fix: call setFrameTopLeftPoint: synchronously via msg_send on the NSPanel before showing, and add positioning to toggle_panel and show_panel. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/panel.rs | 44 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index 2c5165da..3495eb63 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -30,9 +30,30 @@ macro_rules! get_or_init_panel { // Export macro for use in other modules pub(crate) use get_or_init_panel; -/// Show the panel (initializing if needed). +/// Retrieve the tray icon rect and position the panel beneath it. +/// No-ops gracefully if the tray icon or its rect is unavailable. +fn position_panel_from_tray(app_handle: &AppHandle) { + let Some(tray) = app_handle.tray_by_id("tray") else { + log::debug!("position_panel_from_tray: tray icon not found"); + return; + }; + match tray.rect() { + Ok(Some(rect)) => { + position_panel_at_tray_icon(app_handle, rect.position, rect.size); + } + Ok(None) => { + log::debug!("position_panel_from_tray: tray rect not available yet"); + } + Err(e) => { + log::warn!("position_panel_from_tray: failed to get tray rect: {}", e); + } + } +} + +/// Show the panel (initializing if needed), positioned under the tray icon. pub fn show_panel(app_handle: &AppHandle) { if let Some(panel) = get_or_init_panel!(app_handle) { + position_panel_from_tray(app_handle); panel.show_and_make_key(); } } @@ -49,6 +70,7 @@ pub fn toggle_panel(app_handle: &AppHandle) { panel.hide(); } else { log::debug!("toggle_panel: showing panel"); + position_panel_from_tray(app_handle); panel.show_and_make_key(); } } @@ -220,5 +242,23 @@ pub fn position_panel_at_tray_icon( let nudge_up: f64 = 6.0; let panel_y = icon_logical_y + icon_logical_h - nudge_up; - let _ = window.set_position(tauri::LogicalPosition::new(panel_x, panel_y)); + // Set position synchronously on the NSPanel via setFrameTopLeftPoint:. + // Tauri's window.set_position() dispatches through tao's exec_async, so + // the actual setFrameTopLeftPoint runs on the NEXT main run-loop tick. + // If show_and_make_key() fires before that tick, the panel briefly + // appears at the old position (flicker). Bypassing tao and calling the + // selector directly makes the position change immediate. + unsafe { + unsafe extern "C" { + fn CGMainDisplayID() -> u32; + fn CGDisplayPixelsHigh(display: u32) -> u64; + } + + if let Ok(panel_handle) = app_handle.get_webview_panel("main") { + let ns_panel = panel_handle.as_panel(); + let screen_height = CGDisplayPixelsHigh(CGMainDisplayID()) as f64; + let point = tauri_nspanel::NSPoint::new(panel_x, screen_height - panel_y); + let _: () = objc2::msg_send![ns_panel, setFrameTopLeftPoint: point]; + } + } } From ae43e80e9896b833ce3c41b4341d221e09ead9a6 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 7 Apr 2026 14:16:16 +0700 Subject: [PATCH 2/3] fix(panel): correct tray monitor positioning --- src-tauri/src/panel.rs | 154 ++++++++++++++++++++++------------------- 1 file changed, 84 insertions(+), 70 deletions(-) diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index 3495eb63..4da2068d 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -3,6 +3,64 @@ use tauri_nspanel::{ CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel, }; +fn monitor_contains_physical_point( + origin_x: f64, + origin_y: f64, + width: f64, + height: f64, + point_x: f64, + point_y: f64, +) -> bool { + point_x >= origin_x + && point_x < origin_x + width + && point_y >= origin_y + && point_y < origin_y + height +} + +unsafe fn set_panel_frame_top_left(panel: &tauri_nspanel::NSPanel, x: f64, y: f64) { + let point = tauri_nspanel::NSPoint::new(x, y); + let _: () = objc2::msg_send![panel, setFrameTopLeftPoint: point]; +} + +fn set_panel_top_left_immediately( + window: &tauri::WebviewWindow, + app_handle: &AppHandle, + panel_x: f64, + panel_y: f64, + primary_logical_h: f64, +) { + let Ok(panel_handle) = app_handle.get_webview_panel("main") else { + return; + }; + + let target_x = panel_x; + let target_y = primary_logical_h - panel_y; + + if objc2_foundation::MainThreadMarker::new().is_some() { + unsafe { + set_panel_frame_top_left(panel_handle.as_panel(), target_x, target_y); + } + return; + } + + let (tx, rx) = std::sync::mpsc::channel(); + let panel_handle = panel_handle.clone(); + + if let Err(error) = window.run_on_main_thread(move || { + unsafe { + set_panel_frame_top_left(panel_handle.as_panel(), target_x, target_y); + } + let _ = tx.send(()); + }) { + log::warn!("Failed to position panel on main thread: {}", error); + return; + } + + if rx.recv().is_err() { + log::warn!("Failed waiting for panel position on main thread"); + } +} + /// Macro to get existing panel or initialize it if needed. /// Returns Option - Some if panel is available, None on error. macro_rules! get_or_init_panel { @@ -137,17 +195,6 @@ pub fn position_panel_at_tray_icon( ) { let window = app_handle.get_webview_window("main").unwrap(); - // Tray icon events on macOS report coordinates in a hybrid physical space where - // each monitor region uses its own scale (logical_pos × scale = physical origin). - // On mixed-DPI setups this creates overlapping regions, making it impossible to - // reliably determine the correct monitor from tray coordinates alone. - // - // Instead, we use NSEvent::mouseLocation() which returns the cursor position in - // macOS's unified logical (point) coordinate space — always unambiguous regardless - // of how many monitors or scale factors are involved. We find which monitor - // contains the cursor, then convert the tray icon's physical coordinates to - // logical coordinates within that monitor. - let (icon_phys_x, icon_phys_y) = match &icon_position { Position::Physical(pos) => (pos.x as f64, pos.y as f64), Position::Logical(pos) => (pos.x, pos.y), @@ -157,12 +204,6 @@ pub fn position_panel_at_tray_icon( Size::Logical(s) => (s.width, s.height), }; - // Get the cursor's logical position via NSEvent — this is in macOS's flipped - // coordinate system (origin at bottom-left of primary screen). - let mouse_logical = objc2_app_kit::NSEvent::mouseLocation(); - - // Convert from macOS bottom-left origin to top-left origin used by Tauri. - // Primary screen height (in points) defines the flip axis. let monitors = window.available_monitors().expect("failed to get monitors"); let primary_logical_h = window .primary_monitor() @@ -171,35 +212,29 @@ pub fn position_panel_at_tray_icon( .map(|m| m.size().height as f64 / m.scale_factor()) .unwrap_or(0.0); - let mouse_x = mouse_logical.x; - let mouse_y = primary_logical_h - mouse_logical.y; - - // Find the monitor containing the cursor in logical space (no ambiguity). - let mut found_monitor = None; - for m in &monitors { - let pos = m.position(); - let scale = m.scale_factor(); - let logical_w = m.size().width as f64 / scale; - let logical_h = m.size().height as f64 / scale; - - let logical_x = pos.x as f64 / scale; - let logical_y = pos.y as f64 / scale; - let x_in = mouse_x >= logical_x && mouse_x < logical_x + logical_w; - let y_in = mouse_y >= logical_y && mouse_y < logical_y + logical_h; - - if x_in && y_in { - found_monitor = Some(m.clone()); - break; - } - } + let icon_center_x = icon_phys_x + (icon_phys_w / 2.0); + let icon_center_y = icon_phys_y + (icon_phys_h / 2.0); + + let found_monitor = monitors.iter().find(|monitor| { + let origin = monitor.position(); + let size = monitor.size(); + monitor_contains_physical_point( + origin.x as f64, + origin.y as f64, + size.width as f64, + size.height as f64, + icon_center_x, + icon_center_y, + ) + }); let monitor = match found_monitor { - Some(m) => m, + Some(m) => m.clone(), None => { log::warn!( - "No monitor found for cursor at ({:.0}, {:.0}), using primary", - mouse_x, - mouse_y + "No monitor found for tray rect center at ({:.0}, {:.0}), using primary", + icon_center_x, + icon_center_y ); match window.primary_monitor() { Ok(Some(m)) => m, @@ -209,16 +244,13 @@ pub fn position_panel_at_tray_icon( }; let target_scale = monitor.scale_factor(); - let mon_logical_x = monitor.position().x as f64; - let mon_logical_y = monitor.position().y as f64; + let mon_phys_x = monitor.position().x as f64; + let mon_phys_y = monitor.position().y as f64; + let mon_logical_x = mon_phys_x / target_scale; + let mon_logical_y = mon_phys_y / target_scale; - // Convert tray icon physical coords to logical within the identified monitor. - // Physical origin of this monitor in the hybrid tray coordinate space: - let phys_origin_x = mon_logical_x * target_scale; - let phys_origin_y = mon_logical_y * target_scale; - - let icon_logical_x = mon_logical_x + (icon_phys_x - phys_origin_x) / target_scale; - let icon_logical_y = mon_logical_y + (icon_phys_y - phys_origin_y) / target_scale; + let icon_logical_x = mon_logical_x + (icon_phys_x - mon_phys_x) / target_scale; + let icon_logical_y = mon_logical_y + (icon_phys_y - mon_phys_y) / target_scale; let icon_logical_w = icon_phys_w / target_scale; let icon_logical_h = icon_phys_h / target_scale; @@ -242,23 +274,5 @@ pub fn position_panel_at_tray_icon( let nudge_up: f64 = 6.0; let panel_y = icon_logical_y + icon_logical_h - nudge_up; - // Set position synchronously on the NSPanel via setFrameTopLeftPoint:. - // Tauri's window.set_position() dispatches through tao's exec_async, so - // the actual setFrameTopLeftPoint runs on the NEXT main run-loop tick. - // If show_and_make_key() fires before that tick, the panel briefly - // appears at the old position (flicker). Bypassing tao and calling the - // selector directly makes the position change immediate. - unsafe { - unsafe extern "C" { - fn CGMainDisplayID() -> u32; - fn CGDisplayPixelsHigh(display: u32) -> u64; - } - - if let Ok(panel_handle) = app_handle.get_webview_panel("main") { - let ns_panel = panel_handle.as_panel(); - let screen_height = CGDisplayPixelsHigh(CGMainDisplayID()) as f64; - let point = tauri_nspanel::NSPoint::new(panel_x, screen_height - panel_y); - let _: () = objc2::msg_send![ns_panel, setFrameTopLeftPoint: point]; - } - } + set_panel_top_left_immediately(&window, app_handle, panel_x, panel_y, primary_logical_h); } From 995a7fd2fbc05963869f6df6cc0079af7259c366 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 7 Apr 2026 14:41:27 +0700 Subject: [PATCH 3/3] fix(panel): show before tray reposition --- src-tauri/src/panel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index 4da2068d..ce440aee 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -111,8 +111,8 @@ fn position_panel_from_tray(app_handle: &AppHandle) { /// Show the panel (initializing if needed), positioned under the tray icon. pub fn show_panel(app_handle: &AppHandle) { if let Some(panel) = get_or_init_panel!(app_handle) { - position_panel_from_tray(app_handle); panel.show_and_make_key(); + position_panel_from_tray(app_handle); } } @@ -128,8 +128,8 @@ pub fn toggle_panel(app_handle: &AppHandle) { panel.hide(); } else { log::debug!("toggle_panel: showing panel"); - position_panel_from_tray(app_handle); panel.show_and_make_key(); + position_panel_from_tray(app_handle); } }