277 lines
10 KiB
Rust
277 lines
10 KiB
Rust
//! 快速面板弹窗:全局快捷键唤起的多源命令面板。
|
|
//!
|
|
//! 流程(与剪贴板弹窗同构):
|
|
//! 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 specta::Type;
|
|
|
|
/// 弹窗窗口标签
|
|
pub const POPUP_LABEL: &str = "quick-panel";
|
|
|
|
/// 窗口尺寸(逻辑像素)
|
|
const WIN_W: f64 = 600.0;
|
|
const WIN_H: f64 = 420.0;
|
|
|
|
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
|
|
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
|
|
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// 兜底创建路径下 show_popup 计算出的待显示位置(物理坐标),供 show_window 应用,
|
|
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。
|
|
static PENDING_POS: Mutex<Option<(f64, f64)>> = 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<String>,
|
|
}
|
|
|
|
/// 快速面板设置
|
|
#[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<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))
|
|
}
|
|
|
|
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。
|
|
/// 位置默认在屏幕外,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) {
|
|
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
|
|
x: x as i32,
|
|
y: y as i32,
|
|
}));
|
|
let _ = win.show();
|
|
let _ = win.set_focus();
|
|
// 通知前端刷新数据
|
|
let _ = app.emit(crate::constants::events::QUICKPANEL_SHOW, ());
|
|
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,
|
|
}));
|
|
}
|
|
let _ = win.show();
|
|
let _ = win.set_focus();
|
|
// 通知前端刷新数据
|
|
let _ = app.emit(crate::constants::events::QUICKPANEL_SHOW, ());
|
|
}
|
|
}
|
|
|
|
/// 隐藏弹窗(不销毁,保留复用)
|
|
pub fn hide_popup(app: &AppHandle) {
|
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
|
let _ = win.hide();
|
|
}
|
|
}
|