From 5082f3e741d464043d7e1cfe141585effc548f7f Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:40:13 +0530 Subject: [PATCH] feat(overlay): activate main window on orb click (#605) (#611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tauri): add activate_main_window command for overlay (#605) Exposes the existing show_main_window helper as a Tauri command so the overlay webview can bring the main window to front. The command is whitelisted in allow-core-process.toml so the overlay window capability can invoke it. Co-Authored-By: Claude Opus 4.6 * feat(overlay): activate main window on orb click (#605) The overlay orb had no click behavior. Now clicking it in idle mode invokes activate_main_window, bringing the main app window to the front (mirrors the tray icon flow). Since the overlay is an NSPanel NonactivatingPanel on macOS, React's synthesized onClick does not fire. Instead we record the press position on mousedown and emulate click on mouseup when the pointer stayed within a 4px slop. Dragging is deferred to mousemove past the slop so startDragging doesn't swallow the mouseup event. Co-Authored-By: Claude Opus 4.6 * fix(window): propagate show_main_window errors instead of swallowing them (#605) `show_main_window` silently logged failures and returned `()`, so the `activate_main_window` Tauri command could report success on a no-op. Thread `Result<(), String>` through so JS `invoke().catch()` sees real failures, and preserve the previous log-on-error behavior at the tray/Reopen call sites where no caller consumes the result. Co-Authored-By: Claude Opus 4.7 * fix(overlay): stabilize orb click vs drag vs double-click (#605) Two follow-ups to the deferred-drag pattern: 1. Drop stale pressRef when the primary button is no longer held during mousemove. Window-drag / focus changes can steal the mouseup, leaving the ref populated so the next idle hover would start a spurious drag. 2. Debounce the synthetic click by 250 ms so a follow-up dblclick can cancel it — the double-click-to-reset gesture was firing activate + reset together. Clear the timer on dblclick and on unmount. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.6 --- .../permissions/allow-core-process.toml | 1 + app/src-tauri/src/lib.rs | 50 ++++++---- app/src/overlay/OverlayApp.tsx | 97 +++++++++++++++++-- 3 files changed, 122 insertions(+), 26 deletions(-) diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index 11912977f..db05ed0e9 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -13,5 +13,6 @@ allow = [ "service_uninstall_direct", "register_dictation_hotkey", "unregister_dictation_hotkey", + "activate_main_window", ] deny = [] diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index bcfbb1604..249fdbec8 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -404,20 +404,27 @@ fn is_daemon_mode() -> bool { std::env::args().any(|arg| arg == "daemon" || arg == "--daemon") } -fn show_main_window(app: &AppHandle) { - if let Some(window) = app.get_webview_window("main") { - if let Err(err) = window.show() { - log::error!("[tray] failed to show main window: {err}"); - } - if let Err(err) = window.unminimize() { - log::error!("[tray] failed to unminimize main window: {err}"); - } - if let Err(err) = window.set_focus() { - log::error!("[tray] failed to focus main window: {err}"); - } - } else { - log::error!("[tray] main window not found"); - } +/// Tauri command: bring the main window to front from any webview (e.g. overlay orb click). +#[tauri::command] +fn activate_main_window(app: AppHandle) -> Result<(), String> { + log::debug!("[window] activate_main_window called from overlay"); + show_main_window(&app) +} + +fn show_main_window(app: &AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or_else(|| "main window not found".to_string())?; + window + .show() + .map_err(|err| format!("failed to show main window: {err}"))?; + window + .unminimize() + .map_err(|err| format!("failed to unminimize main window: {err}"))?; + window + .set_focus() + .map_err(|err| format!("failed to focus main window: {err}"))?; + Ok(()) } fn setup_tray(app: &AppHandle) -> tauri::Result<()> { @@ -444,7 +451,9 @@ fn setup_tray(app: &AppHandle) -> tauri::Result<()> { .on_menu_event(|app, event| match event.id().as_ref() { "tray_show_window" => { log::info!("[tray] action=show_window source=menu"); - show_main_window(app); + if let Err(err) = show_main_window(app) { + log::error!("[tray] failed to show main window from menu: {err}"); + } } "tray_quit" => { log::info!("[tray] action=quit source=menu"); @@ -460,7 +469,9 @@ fn setup_tray(app: &AppHandle) -> tauri::Result<()> { } = event { log::info!("[tray] action=show_window source=left_click"); - show_main_window(tray.app_handle()); + if let Err(err) = show_main_window(tray.app_handle()) { + log::error!("[tray] failed to show main window from tray click: {err}"); + } } }) .build(app)?; @@ -567,7 +578,8 @@ pub fn run() { service_status_direct, service_uninstall_direct, register_dictation_hotkey, - unregister_dictation_hotkey + unregister_dictation_hotkey, + activate_main_window ]) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -589,7 +601,9 @@ pub fn run() { #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => { log::info!("[window] reopen event — showing main window"); - show_main_window(app_handle); + if let Err(err) = show_main_window(app_handle) { + log::error!("[macos] failed to show main window on reopen: {err}"); + } } RunEvent::Exit => { if let Some(core) = app_handle.try_state::() { diff --git a/app/src/overlay/OverlayApp.tsx b/app/src/overlay/OverlayApp.tsx index a726781a7..9853f3ec4 100644 --- a/app/src/overlay/OverlayApp.tsx +++ b/app/src/overlay/OverlayApp.tsx @@ -194,6 +194,18 @@ export default function OverlayApp() { setBubble(null); }, [clearDismissTimer]); + /** Click handler for the orb: idle → bring main window to front; active → dismiss bubble. */ + const handleOrbClick = useCallback(() => { + if (mode === 'idle') { + console.debug('[overlay] orb clicked while idle — activating main window'); + invoke('activate_main_window').catch(err => { + console.error('[overlay] failed to activate main window:', err); + }); + } else { + goIdle(); + } + }, [mode, goIdle]); + // ── Dictation: pressed / released ────────────────────────────────────── const handleDictationToggle = useCallback( (payload: DictationTogglePayload) => { @@ -420,16 +432,43 @@ export default function OverlayApp() { userDraggedRef.current = false; }, []); - /** Initiate native window drag on mouse-down. */ - const handleDragStart = useCallback( + // NSPanel (non-activating overlay) doesn't deliver synthesized `click` + // events to the webview, and calling `startDragging()` eagerly on + // mouse-down blocks `mouseup` from firing. We instead arm the drag only + // after the pointer moves past a small threshold, so a pure click fires + // `mouseup` normally and we can activate the main window there. + const pressRef = useRef<{ x: number; y: number; dragStarted: boolean } | null>(null); + const CLICK_SLOP_PX = 4; + /** Pending single-click, deferred so a follow-up double-click can cancel it. */ + const clickTimerRef = useRef(null); + const CLICK_DOUBLE_CLICK_DELAY_MS = 250; + + /** Record mouse-down position; defer drag until the pointer actually moves. */ + const handleDragStart = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + pressRef.current = { x: e.screenX, y: e.screenY, dragStarted: false }; + }, []); + + /** If pointer moves past the slop, escalate into a native window drag. */ + const handleMouseMove = useCallback( async (e: React.MouseEvent) => { - // Only drag on primary button; ignore if a click handler should fire - if (e.button !== 0) return; - e.preventDefault(); + const press = pressRef.current; + if (!press) return; + // If the primary button is no longer held, a prior mouseup was missed + // (window-drag steals it, focus change, etc). Drop the stale press so + // we don't spuriously start a drag on a hover. + if ((e.buttons & 1) === 0) { + pressRef.current = null; + return; + } + if (press.dragStarted) return; + const dx = Math.abs(e.screenX - press.x); + const dy = Math.abs(e.screenY - press.y); + if (dx <= CLICK_SLOP_PX && dy <= CLICK_SLOP_PX) return; + press.dragStarted = true; try { const appWindow = getCurrentWindow(); await appWindow.startDragging(); - // After the drag completes, persist the new position void persistPosition(); } catch { // startDragging can fail if not supported — fall through silently @@ -438,6 +477,47 @@ export default function OverlayApp() { [persistPosition] ); + /** + * On mouse-up, treat as a click if no drag was initiated. Emulates + * `onClick` for the non-activating panel. The click is deferred briefly + * so a follow-up `dblclick` (used to reset position) can cancel it. + */ + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + const press = pressRef.current; + pressRef.current = null; + if (e.button !== 0 || !press) return; + if (press.dragStarted) return; + if (clickTimerRef.current !== null) { + window.clearTimeout(clickTimerRef.current); + } + clickTimerRef.current = window.setTimeout(() => { + clickTimerRef.current = null; + console.debug('[overlay] orb mouseup → click'); + handleOrbClick(); + }, CLICK_DOUBLE_CLICK_DELAY_MS); + }, + [handleOrbClick] + ); + + /** Double-click resets position — cancel any pending single-click first. */ + const handleDoubleClick = useCallback(() => { + if (clickTimerRef.current !== null) { + window.clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } + resetPosition(); + }, [resetPosition]); + + useEffect(() => { + return () => { + if (clickTimerRef.current !== null) { + window.clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } + }; + }, []); + useEffect(() => { const appWindow = getCurrentWindow(); const isActive = status === 'active'; @@ -551,9 +631,10 @@ export default function OverlayApp() { ? 'Attention message' : 'OpenHuman overlay' } - onClick={goIdle} onMouseDown={handleDragStart} - onDoubleClick={resetPosition} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onDoubleClick={handleDoubleClick} onMouseEnter={() => { setIsHovered(true); }}