快速面板模块
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user