//! 快速面板弹窗:全局快捷键唤起的多源命令面板。 //! //! 流程(与剪贴板弹窗同构): //! 1. 应用启动 → `ensure_window` 预创建隐藏窗口(屏幕外) //! 2. 全局快捷键按下 → `show_popup` 在鼠标所在显示器中央定位并显示 //! 3. 前端 Vue 挂载完成、主题应用后调用 `quickpanel_show_window` 显示窗口 //! 4. 前端监听 `quickpanel-show` 事件刷新数据/聚焦输入 //! 5. 窗口失焦自动隐藏(保留窗口复用,不销毁) //! //! Win32 API(鼠标/显示器/DPI)复用 clipboard::popup 已 pub use 的实现, //! 避免重复封装。 use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use tauri::window::{Effect, EffectsBuilder}; use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point}; use super::explorer; use specta::Type; /// 弹窗窗口标签 pub const POPUP_LABEL: &str = "quick-panel"; /// 窗口尺寸(逻辑像素) const WIN_W: f64 = 600.0; const WIN_H: f64 = 420.0; /// `quickpanel-show` 事件负载:携带快捷键按下时检测到的 Explorer 当前目录, /// 前端据此渲染"当前目录"文件操作分组。 #[derive(Clone, Serialize)] pub struct QuickPanelShowPayload { pub dir: Option, } /// 检测前台 Explorer 目录并连同 show 事件一起下发。 /// 必须在快捷键回调内调用:此时前台窗口仍是 Explorer,面板尚未抢焦点。 fn emit_show(app: &AppHandle) { let dir = explorer::detect_explorer_folder(); crate::logger::log_info("quickpanel", &format!("show_popup: explorer_dir={:?}", dir)); let _ = app.emit( crate::constants::events::QUICKPANEL_SHOW, QuickPanelShowPayload { dir }, ); } /// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。 /// 预创建路径不设置,避免应用启动时弹窗自动弹出。 static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false); /// 兜底创建路径下 show_popup 计算出的待显示位置(物理坐标),供 show_window 应用, /// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。 static PENDING_POS: Mutex> = Mutex::new(None); /// 自定义命令 #[derive(Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct CustomCommand { pub id: String, pub title: String, pub command: String, #[serde(default)] pub args: Vec, } /// 快速面板设置 #[derive(Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct QuickPanelSettings { /// 全局快捷键(如 "Alt+Space"),空字符串表示不注册。 #[serde(default = "default_shortcut")] pub shortcut: String, /// 唤起位置:center(鼠标所在显示器中央)| cursor(鼠标位置) #[serde(default = "default_popup_position")] pub popup_position: String, /// 默认搜索引擎:google | bing | baidu #[serde(default = "default_search_engine")] pub search_engine: String, /// 文件索引目录列表(空列表表示使用默认:桌面/文档/下载) #[serde(default = "default_index_dirs")] pub index_dirs: Vec, /// 自定义命令列表 #[serde(default)] pub custom_commands: Vec, } fn default_shortcut() -> String { "Alt+Space".to_string() } fn default_popup_position() -> String { "center".to_string() } fn default_search_engine() -> String { "bing".to_string() } fn default_index_dirs() -> Vec { // 桌面/文档/下载目录(延迟到实际使用时解析,避免启动时失败) let mut dirs = Vec::new(); if let Some(d) = dirs::desktop_dir() { dirs.push(d.to_string_lossy().to_string()); } if let Some(d) = dirs::document_dir() { dirs.push(d.to_string_lossy().to_string()); } if let Some(d) = dirs::download_dir() { dirs.push(d.to_string_lossy().to_string()); } dirs } impl Default for QuickPanelSettings { fn default() -> Self { Self { shortcut: default_shortcut(), popup_position: default_popup_position(), search_engine: default_search_engine(), index_dirs: default_index_dirs(), custom_commands: Vec::new(), } } } /// 设置文件路径:{app_data_dir}/quickpanel/settings.json fn settings_path(app: &AppHandle) -> PathBuf { app.path() .app_data_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join("quickpanel") .join("settings.json") } /// 读取设置,文件不存在或解析失败返回默认值 pub fn load_settings(app: &AppHandle) -> QuickPanelSettings { let path = settings_path(app); std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } /// 保存设置到磁盘 pub fn save_settings(app: &AppHandle, settings: &QuickPanelSettings) -> Result<(), String> { let path = settings_path(app); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("创建设置目录失败: {}", e))?; } let json = serde_json::to_string_pretty(settings).map_err(|e| format!("序列化设置失败: {}", e))?; std::fs::write(&path, json).map_err(|e| format!("写入设置文件失败: {}", e)) } /// 创建弹窗窗口(隐藏状态)并注册失焦监听。 /// 位置默认在屏幕外,show_popup 时会重新定位到鼠标所在显示器中央。 /// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。 fn create_popup_window(app: &AppHandle) { let win = match WebviewWindowBuilder::new( app, POPUP_LABEL, WebviewUrl::App("index.html#quick-panel".into()), ) .title("快速面板") .inner_size(WIN_W, WIN_H) .position(-10000.0, -10000.0) // 屏幕外,避免隐藏时一闪 .decorations(false) .transparent(true) .shadow(true) .always_on_top(true) .skip_taskbar(true) .resizable(false) .visible(false) .focused(false) // 不抢占焦点,避免创建即触发 Focused(false) .effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build()) .build() { Ok(w) => w, Err(e) => { crate::logger::log_error("quickpanel", &format!("创建弹窗失败: {}", e)); return; } }; // 监听窗口失焦:自动隐藏 let app_handle = app.clone(); let win_handle = win.clone(); win.on_window_event(move |event| { if let tauri::WindowEvent::Focused(false) = event { let _ = win_handle.hide(); let _ = app_handle.emit(crate::constants::events::QUICKPANEL_HIDE, ()); } }); crate::logger::log_info("quickpanel", "弹窗窗口已预创建(隐藏状态)"); } /// 应用启动时预创建弹窗窗口(隐藏)。 /// 这样首次按快捷键时窗口已存在,直接 show + 定位,避免首次创建时序问题。 pub fn ensure_window(app: &AppHandle) { if app.get_webview_window(POPUP_LABEL).is_some() { return; } create_popup_window(app); } /// 在指定位置显示弹窗。 /// popup_position = "cursor" 时在鼠标位置附近显示,否则在鼠标所在显示器中央显示。 /// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。 pub fn show_popup(app: &AppHandle) { let settings = load_settings(app); let cursor_mode = settings.popup_position == "cursor"; // 获取鼠标位置(物理像素) let (mx, my) = match get_cursor_pos() { Some(p) => p, None => return, }; // 获取光标所在显示器的工作区(物理像素,与 get_cursor_pos 同一坐标系) let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx, my) .unwrap_or((0, 0, 1920, 1040)); // 光标所在显示器的 DPI:窗口尺寸需按物理像素换算 let dpi = get_dpi_for_point(mx, my).unwrap_or(96); let scale = dpi as f64 / 96.0; let win_w_px = WIN_W * scale; let win_h_px = WIN_H * scale; // 直接以物理坐标计算(光标 + 工作区均为物理像素,避免混合 DPI 下换算偏移) let (x, y) = if cursor_mode { // 鼠标位置模式:以鼠标为基准偏移,clamp 到工作区内 let x = (mx as f64 + 12.0 * scale).min(wa_right as f64 - win_w_px).max(wa_left as f64); let y = (my as f64 + 12.0 * scale).min(wa_bottom as f64 - win_h_px).max(wa_top as f64); (x, y) } else { // 中央模式:窗口居中于鼠标所在显示器工作区 let wa_w = (wa_right - wa_left) as f64; let wa_h = (wa_bottom - wa_top) as f64; ( wa_left as f64 + (wa_w - win_w_px) / 2.0, wa_top as f64 + (wa_h - win_h_px) / 2.0, ) }; // 窗口已存在:移动 + 显示 + 请求焦点 if let Some(win) = app.get_webview_window(POPUP_LABEL) { // 必须先于窗口显示/聚焦检测 Explorer 目录:show/set_focus 会立即抢走前台焦点, // 之后调用 GetForegroundWindow 拿到的就是面板自身了。 emit_show(app); let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x: x as i32, y: y as i32, })); let _ = win.show(); let _ = win.set_focus(); return; } // 兜底:窗口被销毁时重新创建(隐藏),等前端 onMounted 回调 show_window POPUP_PENDING_SHOW.store(true, Ordering::SeqCst); if let Ok(mut pos) = PENDING_POS.lock() { *pos = Some((x, y)); } create_popup_window(app); } /// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。 /// 预创建路径下前端 onMounted 也会调用此函数,但 POPUP_PENDING_SHOW 为 false 时直接跳过, /// 避免应用启动时弹窗自动弹出。仅 show_popup 兜底创建路径才真正显示。 pub fn show_window(app: &AppHandle) { if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) { return; } if let Some(win) = app.get_webview_window(POPUP_LABEL) { // 应用 show_popup 计算的兜底位置(物理坐标),避免停留在屏幕外 let pos = PENDING_POS.lock().ok().and_then(|p| *p); if let Some((x, y)) = pos { let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x: x as i32, y: y as i32, })); } // 同样先检测 Explorer 目录再显示,避免面板抢焦点导致检测失败。 emit_show(app); let _ = win.show(); let _ = win.set_focus(); } } /// 隐藏弹窗(不销毁,保留复用) pub fn hide_popup(app: &AppHandle) { if let Some(win) = app.get_webview_window(POPUP_LABEL) { let _ = win.hide(); } }