From 330246dbf08cee1734ab061bfbfbe6a71e246749 Mon Sep 17 00:00:00 2001 From: Justin Tahara <105671973+justin-tahara@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:54:06 -0700 Subject: [PATCH] feat(desktop): summon shortcut toggles -- press while focused hides (#13803) --- desktop/src-tauri/src/menu.rs | 14 +---- desktop/src-tauri/src/shortcuts.rs | 85 +++++++++++++++++++++++++++++- desktop/src-tauri/src/window.rs | 12 +++++ 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/menu.rs b/desktop/src-tauri/src/menu.rs index c8b6ef8a1a..b2b05af8b7 100644 --- a/desktop/src-tauri/src/menu.rs +++ b/desktop/src-tauri/src/menu.rs @@ -1,7 +1,7 @@ use crate::config::ConfigState; use crate::debug_log::{log_backend_error, MENU_OPEN_DEBUG_LOG_ID, MENU_TOGGLE_DEVTOOLS_ID}; use crate::shortcuts::SummonShortcut; -use crate::window::{focus_main_window, open_chat_window}; +use crate::window::{focus_main_window, focused_webview_window, open_chat_window}; use tauri::image::Image; #[cfg(not(target_os = "macos"))] use tauri::menu::AboutMetadataBuilder; @@ -131,18 +131,6 @@ fn build_view_menu(app: &AppHandle, menu: &Menu) -> tauri::Result<()> { Ok(()) } -/// The window a View-menu action should apply to: whichever one is focused -/// (menu accelerators only fire while the app is active, but the app can have -/// several windows). -fn focused_webview_window(app: &AppHandle) -> Option { - app.webview_windows().into_values().find(|window| { - window.is_focused().unwrap_or_else(|e| { - log_backend_error(app, &format!("Failed to query window focus: {e}")); - false - }) - }) -} - pub fn handle_reload(app: &AppHandle) { if let Some(window) = focused_webview_window(app) { crate::commands::reload_page(window); diff --git a/desktop/src-tauri/src/shortcuts.rs b/desktop/src-tauri/src/shortcuts.rs index b2ab9ae55f..970bed0b81 100644 --- a/desktop/src-tauri/src/shortcuts.rs +++ b/desktop/src-tauri/src/shortcuts.rs @@ -1,6 +1,7 @@ use crate::config::ConfigState; use crate::debug_log::{log_backend_debug, log_backend_error}; -use crate::window::{focus_main_window, open_chat_window}; +use crate::window::{focus_main_window, focused_webview_window, open_chat_window}; +use std::sync::{Mutex, PoisonError}; use tauri::{AppHandle, Manager}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; @@ -11,6 +12,11 @@ pub struct SummonShortcut { pub chord: String, } +/// Labels of the windows the last chord dismissal hid. Auxiliary windows +/// have no other path back once hidden (the tray and summon paths only +/// manage `main`), so the next summon restores this exact set. +struct DismissedWindows(Mutex>); + /// Register the configured summon shortcut. This is deliberately the only /// global (system-wide) shortcut the app registers: summoning Onyx is the one /// action that must work while another app is focused. Every other shortcut @@ -30,12 +36,24 @@ pub fn setup_global_shortcuts(app: &AppHandle) { } }; + app.manage(DismissedWindows(Mutex::new(Vec::new()))); + let result = app .global_shortcut() .on_shortcut(shortcut, |app, _shortcut, event| { if event.state() != ShortcutState::Pressed { return; } + // Toggle contract: the chord that summons Onyx also dismisses it, + // returning focus to whatever app was active before. Dismissal + // hides every visible Onyx window -- hiding only the focused one + // would just hand focus to the next Onyx window instead of the + // previous application. + if focused_webview_window(app).is_some() { + dismiss_all_windows(app); + return; + } + restore_dismissed_windows(app); let opens_new_chat = app.state::().config().summon_opens_new_chat; log_backend_debug( app, @@ -65,6 +83,71 @@ pub fn setup_global_shortcuts(app: &AppHandle) { } } +fn dismiss_all_windows(app: &AppHandle) { + let mut hidden = Vec::new(); + for (label, window) in app.webview_windows() { + let visible = window.is_visible().unwrap_or_else(|e| { + log_backend_error(app, &format!("Failed to query window visibility: {e}")); + false + }); + if !visible { + continue; + } + if let Err(e) = window.hide() { + log_backend_error(app, &format!("Failed to hide window \"{label}\": {e}")); + } else { + hidden.push(label); + } + } + log_backend_debug( + app, + &format!( + "Summon shortcut fired (action=dismiss, windows={})", + hidden.len() + ), + ); + // Merge rather than replace: the queue may still hold labels whose + // restore failed earlier, and overwriting would strand those windows. + if let Some(state) = app.try_state::() { + let mut queued = state.0.lock().unwrap_or_else(PoisonError::into_inner); + for label in hidden { + if !queued.contains(&label) { + queued.push(label); + } + } + } +} + +/// Re-show the windows the last dismissal hid. `main` is skipped here; the +/// summon action that follows shows and focuses it. A window whose `show` +/// fails stays queued so a later summon retries it -- dropping the label +/// would strand the window hidden with no path back. +fn restore_dismissed_windows(app: &AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + let labels = std::mem::take(&mut *state.0.lock().unwrap_or_else(PoisonError::into_inner)); + let mut failed = Vec::new(); + for label in labels { + if label == "main" { + continue; + } + if let Some(window) = app.get_webview_window(&label) { + if let Err(e) = window.show() { + log_backend_error(app, &format!("Failed to restore window \"{label}\": {e}")); + failed.push(label); + } + } + } + if !failed.is_empty() { + state + .0 + .lock() + .unwrap_or_else(PoisonError::into_inner) + .extend(failed); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index 4299019da9..b679aed955 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -14,6 +14,18 @@ use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; const TITLEBAR_SCRIPT: &str = include_str!("../../src/titlebar.js"); const CHAT_LINK_INTERCEPT_SCRIPT: &str = include_str!("scripts/chat_link_intercept.js"); +/// The window a shortcut or menu action should apply to: whichever one is +/// focused (the app can have several windows). Focus-query failures are +/// logged and treated as unfocused. +pub fn focused_webview_window(app: &AppHandle) -> Option { + app.webview_windows().into_values().find(|window| { + window.is_focused().unwrap_or_else(|e| { + log_backend_error(app, &format!("Failed to query window focus: {e}")); + false + }) + }) +} + pub fn focus_main_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { if let Err(e) = window.unminimize() {