快速面板模块

This commit is contained in:
zhongluofeng
2026-08-04 13:37:42 +08:00
parent 5656193b5c
commit 126f8896b6
23 changed files with 4048 additions and 52 deletions
+311
View File
@@ -0,0 +1,311 @@
//! 快速面板弹窗:全局快捷键唤起的多源命令面板。
//!
//! 流程(与剪贴板弹窗同构):
//! 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 tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use crate::clipboard::popup::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
/// 弹窗窗口标签
pub const POPUP_LABEL: &str = "quick-panel";
/// 窗口尺寸(逻辑像素)
const WIN_W: f64 = 600.0;
const WIN_H: f64 = 420.0;
/// 当前注册的快捷键(用于注销旧快捷键)
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None);
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
/// 自定义命令
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomCommand {
pub id: String,
pub title: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
/// 快速面板设置
#[derive(Clone, Serialize, Deserialize)]
#[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<String>,
/// 自定义命令列表
#[serde(default)]
pub custom_commands: Vec<CustomCommand>,
}
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<String> {
// 桌面/文档/下载目录(延迟到实际使用时解析,避免启动时失败)
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))
}
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+Space"、"Ctrl+Shift+P"
/// 失败返回 None。
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
s.trim().parse::<Shortcut>().ok()
}
/// 注册全局快捷键。重复调用会先注销旧快捷键。
/// 传入空字符串则仅注销不注册。
pub fn register_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), String> {
// 先注销旧快捷键
unregister_shortcut(app);
if shortcut_str.trim().is_empty() {
return Ok(());
}
let shortcut = parse_shortcut(shortcut_str)
.ok_or_else(|| format!("无效的快捷键: {}", shortcut_str))?;
let app_handle = app.clone();
app.global_shortcut()
.on_shortcut(shortcut, move |_app, _shortcut, event| {
// 仅在按下时触发(松开不触发)
if event.state == ShortcutState::Pressed {
show_popup(&app_handle);
}
})
.map_err(|e| format!("注册快捷键失败: {}", e))?;
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
*cur = Some(shortcut_str.to_string());
}
eprintln!("[quickpanel] 已注册快捷键: {}", shortcut_str);
Ok(())
}
/// 注销当前快捷键
pub fn unregister_shortcut(app: &AppHandle) {
if let Ok(cur) = CURRENT_SHORTCUT.lock() {
if let Some(ref s) = *cur {
if let Some(shortcut) = parse_shortcut(s) {
let _ = app.global_shortcut().unregister(shortcut);
}
}
}
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
*cur = None;
}
}
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。
/// 位置默认在屏幕外,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) => {
eprintln!("[quickpanel] 创建弹窗失败: {}", 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("quickpanel-hide", ());
}
});
eprintln!("[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,将物理坐标转为逻辑坐标(DIP)
let dpi = get_dpi_for_point(mx, my).unwrap_or(96);
let scale = dpi as f64 / 96.0;
let wa_left_l = wa_left as f64 / scale;
let wa_top_l = wa_top as f64 / scale;
let wa_right_l = wa_right as f64 / scale;
let wa_bottom_l = wa_bottom as f64 / scale;
let (x, y) = if cursor_mode {
// 鼠标位置模式:以鼠标为基准偏移,clamp 到工作区内
let mx_l = mx as f64 / scale;
let my_l = my as f64 / scale;
let x = (mx_l + 12.0).min(wa_right_l - WIN_W).max(wa_left_l);
let y = (my_l + 12.0).min(wa_bottom_l - WIN_H).max(wa_top_l);
(x, y)
} else {
// 中央模式:窗口居中于鼠标所在显示器工作区
let wa_w = wa_right_l - wa_left_l;
let wa_h = wa_bottom_l - wa_top_l;
(wa_left_l + (wa_w - WIN_W) / 2.0, wa_top_l + (wa_h - WIN_H) / 2.0)
};
// 窗口已存在:移动 + 显示 + 请求焦点
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }));
let _ = win.show();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit("quickpanel-show", ());
return;
}
// 兜底:窗口被销毁时重新创建(隐藏),等前端 onMounted 回调 show_window
POPUP_PENDING_SHOW.store(true, Ordering::SeqCst);
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) {
let _ = win.show();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit("quickpanel-show", ());
}
}
/// 隐藏弹窗(不销毁,保留复用)
pub fn hide_popup(app: &AppHandle) {
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.hide();
}
}