feat: custom hotkey with global shortcut

This commit is contained in:
PandaDEV 2024-11-15 17:39:20 +10:00
parent 02becca60d
commit ba743f7961
No known key found for this signature in database
GPG key ID: 13EFF9BAF70EE75C
7 changed files with 774 additions and 297 deletions

View file

@ -11,12 +11,14 @@ import { onMounted } from 'vue'
onMounted(async () => { onMounted(async () => {
await listen('change_keybind', async () => { await listen('change_keybind', async () => {
console.log("change_keybind");
await navigateTo('/keybind') await navigateTo('/keybind')
await app.show(); await app.show();
await window.getCurrentWindow().show(); await window.getCurrentWindow().show();
}) })
await listen('main_route', async () => { await listen('main_route', async () => {
console.log("main_route");
await navigateTo('/') await navigateTo('/')
}) })
}) })

View file

@ -1,32 +1,45 @@
<template> <template>
<div class="bg"> <div class="bg">
<div class="back"> <div class="back">
<img @click="router.push('/')" src="../public/back_arrow.svg"> <img @click="router.push('/')" src="../public/back_arrow.svg" />
<p>Back</p> <p>Back</p>
</div> </div>
<div class="bottom-bar"> <div class="bottom-bar">
<div class="left"> <div class="left">
<img alt="" class="logo" src="../public/logo.png" width="18px"> <img alt="" class="logo" src="../public/logo.png" width="18px" />
<p>Qopy</p> <p>Qopy</p>
</div> </div>
<div class="right"> <div class="right">
<div @click="saveKeybind" class="actions"> <div @click="saveKeybind" class="actions">
<p>Save</p> <p>Save</p>
<div> <div>
<img alt="" src="../public/ctrl.svg" v-if="os === 'windows' || os === 'linux'"> <img alt="" src="../public/cmd.svg" v-if="os === 'macos'" />
<img alt="" src="../public/cmd.svg" v-if="os === 'macos'"> <img alt="" src="../public/ctrl.svg" v-if="os === 'linux' || os === 'windows'" />
<img alt="" src="../public/enter.svg"> <img alt="" src="../public/enter.svg" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="keybind-container"> <div class="keybind-container">
<h2 class="title">Record a new Hotkey</h2> <h2 class="title">Record a new Hotkey</h2>
<div @blur="onBlur" @focus="onFocus" @keydown="onKeyDown" @keyup="onKeyUp" class="keybind-input" <div
ref="keybindInput" tabindex="0"> @blur="onBlur"
<span class="key" v-if="currentKeybind.length === 0">Click here</span> @focus="onFocus"
@keydown="onKeyDown"
class="keybind-input"
ref="keybindInput"
tabindex="0"
>
<span class="key" v-if="keybind.length === 0">Click here</span>
<template v-else> <template v-else>
<span :key="index" class="key" v-for="(key, index) in currentKeybind">{{ keyToDisplay(key) }}</span> <span
:key="index"
class="key"
:class="{ modifier: isModifier(key) }"
v-for="(key, index) in keybind"
>
{{ keyToDisplay(key) }}
</span>
</template> </template>
</div> </div>
</div> </div>
@ -35,42 +48,59 @@
<script setup lang="ts"> <script setup lang="ts">
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { platform } from '@tauri-apps/plugin-os'; import { platform } from '@tauri-apps/plugin-os';
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const activeModifiers = ref<Set<string>>(new Set()); const activeModifiers = reactive<Set<string>>(new Set());
const currentKeybind = ref<string[]>([]);
const isKeybindInputFocused = ref(false); const isKeybindInputFocused = ref(false);
const keybind = ref<string[]>([]);
const keybindInput = ref<HTMLElement | null>(null); const keybindInput = ref<HTMLElement | null>(null);
const lastNonModifier = ref(''); const lastBlurTime = ref(0);
const os = ref(''); const os = ref('');
const router = useRouter(); const router = useRouter();
const lastBlurTime = ref(0);
const keyToDisplayMap: Record<string, string> = { const keyToDisplayMap: Record<string, string> = {
" ": "Space", ' ': 'Space',
Alt: "Alt", Alt: 'Alt',
ArrowDown: "↓", AltLeft: 'Alt L',
ArrowLeft: "←", AltRight: 'Alt R',
ArrowRight: "→", ArrowDown: '↓',
ArrowUp: "↑", ArrowLeft: '←',
Control: "Ctrl", ArrowRight: '→',
Enter: "↵", ArrowUp: '↑',
Meta: "Meta", Control: 'Ctrl',
Shift: "⇧", ControlLeft: 'Ctrl L',
ControlRight: 'Ctrl R',
Enter: '↵',
Meta: 'Meta',
MetaLeft: 'Meta L',
MetaRight: 'Meta R',
Shift: '⇧',
ShiftLeft: '⇧ L',
ShiftRight: '⇧ R',
}; };
const modifierKeySet = new Set(["Alt", "Control", "Meta", "Shift"]); const modifierKeySet = new Set([
'Alt', 'AltLeft', 'AltRight',
'Control', 'ControlLeft', 'ControlRight',
'Meta', 'MetaLeft', 'MetaRight',
'Shift', 'ShiftLeft', 'ShiftRight'
]);
function keyToDisplay(key: string): string { const isModifier = (key: string): boolean => {
return keyToDisplayMap[key] || key.toUpperCase(); return modifierKeySet.has(key);
} };
function updateCurrentKeybind() { const keyToDisplay = (key: string): string => {
const modifiers = Array.from(activeModifiers.value); return keyToDisplayMap[key] || key;
currentKeybind.value = lastNonModifier.value ? [...modifiers, lastNonModifier.value] : modifiers; };
}
const updateKeybind = () => {
const modifiers = Array.from(activeModifiers).sort();
const nonModifiers = keybind.value.filter(key => !isModifier(key));
keybind.value = [...modifiers, ...nonModifiers];
};
const onBlur = () => { const onBlur = () => {
isKeybindInputFocused.value = false; isKeybindInputFocused.value = false;
@ -79,46 +109,53 @@ const onBlur = () => {
const onFocus = () => { const onFocus = () => {
isKeybindInputFocused.value = true; isKeybindInputFocused.value = true;
activeModifiers.value.clear(); activeModifiers.clear();
lastNonModifier.value = ''; keybind.value = [];
updateCurrentKeybind();
}; };
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
event.preventDefault(); event.preventDefault();
const key = event.key; const key = event.code;
if (key === "Escape") { if (key === 'Escape') {
if (keybindInput.value) { if (keybindInput.value) {
keybindInput.value.blur(); keybindInput.value.blur();
} }
return; return;
} }
if (modifierKeySet.has(key)) { if (isModifier(key)) {
activeModifiers.value.add(key); activeModifiers.add(key);
} else { } else if (!keybind.value.includes(key)) {
lastNonModifier.value = key; keybind.value = keybind.value.filter(k => isModifier(k));
keybind.value.push(key);
} }
updateCurrentKeybind();
};
const onKeyUp = (event: KeyboardEvent) => { updateKeybind();
event.preventDefault();
}; };
const saveKeybind = async () => { const saveKeybind = async () => {
console.log("New:", currentKeybind.value); console.log('New:', keybind.value);
console.log("Old: " + new Array(await invoke("get_keybind"))); const oldKeybind = await invoke<string[]>('get_keybind');
await invoke("save_keybind", { keybind: currentKeybind.value}) console.log('Old:', oldKeybind);
await invoke('save_keybind', { keybind: keybind.value });
}; };
const handleGlobalKeyDown = (event: KeyboardEvent) => { const handleGlobalKeyDown = (event: KeyboardEvent) => {
const now = Date.now(); const now = Date.now();
if ((os.value === 'macos' ? event.metaKey : event.ctrlKey) && event.key === 'Enter' && !isKeybindInputFocused.value) { if (
(os.value === 'macos'
? (event.code === 'MetaLeft' || event.code === 'MetaRight') && event.key === 'Enter'
: (event.code === 'ControlLeft' || event.code === 'ControlRight') && event.key === 'Enter') &&
!isKeybindInputFocused.value
) {
event.preventDefault(); event.preventDefault();
saveKeybind(); saveKeybind();
} else if (event.key === 'Escape' && !isKeybindInputFocused.value && now - lastBlurTime.value > 100) { } else if (
event.key === 'Escape' &&
!isKeybindInputFocused.value &&
now - lastBlurTime.value > 100
) {
event.preventDefault(); event.preventDefault();
router.push('/'); router.push('/');
} }

723
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,13 @@
[package] [package]
name = "qopy" name = "qopy"
version = "0.1.1" version = "0.2.0"
description = "Qopy" description = "Qopy"
authors = ["pandadev"] authors = ["pandadev"]
edition = "2021" edition = "2021"
rust-version = "1.70" rust-version = "1.70"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.0.1", features = [] } tauri-build = { version = "2.0.3", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.0.1", features = [ tauri = { version = "2.0.1", features = [
@ -15,29 +15,30 @@ tauri = { version = "2.0.1", features = [
"tray-icon", "tray-icon",
"image-png", "image-png",
] } ] }
tauri-plugin-sql = { version = "2.0.1", features = ["sqlite"] } tauri-plugin-sql = { version = "2.0.2", features = ["sqlite"] }
tauri-plugin-autostart = "2.0.1" tauri-plugin-autostart = "2.0.1"
tauri-plugin-os = "2.0.1" tauri-plugin-os = "2.0.1"
tauri-plugin-updater = "2.0.2" tauri-plugin-updater = "2.0.2"
tauri-plugin-dialog = "2.0.1" tauri-plugin-dialog = "2.0.3"
tauri-plugin-fs = "2.0.1" tauri-plugin-fs = "2.0.3"
tauri-plugin-clipboard = "2.1.9" tauri-plugin-clipboard = "2.1.11"
tauri-plugin-prevent-default = "0.6.1" tauri-plugin-prevent-default = "0.7.5"
tauri-plugin-global-shortcut = "2.0.1" tauri-plugin-global-shortcut = "2.0.1"
sqlx = { version = "0.8.2", features = ["runtime-tokio-native-tls", "sqlite"] } sqlx = { version = "0.8.2", features = ["runtime-tokio-native-tls", "sqlite"] }
serde = { version = "1.0.210", features = ["derive"] } serde = { version = "1.0.215", features = ["derive"] }
tokio = { version = "1.40.0", features = ["full"] } tokio = { version = "1.41.1", features = ["full"] }
serde_json = "1.0.128" serde_json = "1.0.132"
rdev = "0.5.3" rdev = "0.5.3"
rand = "0.8" rand = "0.8"
base64 = "0.22.1" base64 = "0.22.1"
image = "0.25.2" image = "0.25.5"
reqwest = { version = "0.12.8", features = ["blocking"] } reqwest = { version = "0.12.9", features = ["blocking"] }
url = "2.5.2" url = "2.5.3"
regex = "1.11.0" regex = "1.11.1"
sha2 = "0.10.6" sha2 = "0.10.6"
lazy_static = "1.4.0" lazy_static = "1.4.0"
time = "0.3" time = "0.3"
global-hotkey = "0.6.3"
[features] [features]
custom-protocol = ["tauri/custom-protocol"] custom-protocol = ["tauri/custom-protocol"]

View file

@ -1,10 +1,10 @@
use rand::distributions::Alphanumeric; use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng}; use rand::{thread_rng, Rng};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json;
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use std::fs; use std::fs;
use tauri::Manager; use tauri::{Manager, Emitter};
use tauri::State;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@ -118,19 +118,23 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
#[tauri::command] #[tauri::command]
pub async fn save_keybind( pub async fn save_keybind(
app_handle: tauri::AppHandle,
keybind: Vec<String>, keybind: Vec<String>,
pool: State<'_, SqlitePool>, pool: tauri::State<'_, SqlitePool>,
) -> Result<(), String> { ) -> Result<(), String> {
let json = serde_json::to_string(&keybind).map_err(|e| e.to_string())?; let json = serde_json::to_string(&keybind).map_err(|e| e.to_string())?;
sqlx::query( sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('keybind', ?)")
"INSERT OR REPLACE INTO settings (key, value) VALUES ('keybind', ?)"
)
.bind(json) .bind(json)
.execute(&*pool) .execute(&*pool)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let keybind_str = keybind.join("+");
app_handle
.emit("update-shortcut", keybind_str)
.map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
@ -138,18 +142,20 @@ pub async fn save_keybind(
pub async fn get_keybind(app_handle: tauri::AppHandle) -> Result<Vec<String>, String> { pub async fn get_keybind(app_handle: tauri::AppHandle) -> Result<Vec<String>, String> {
let pool = app_handle.state::<SqlitePool>(); let pool = app_handle.state::<SqlitePool>();
let result = sqlx::query_scalar::<_, String>( let result =
"SELECT value FROM settings WHERE key = 'keybind'" sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key = 'keybind'")
)
.fetch_optional(&*pool) .fetch_optional(&*pool)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
match result { match result {
Some(json) => { Some(json) => {
let setting: KeybindSetting = serde_json::from_str(&json).map_err(|e| e.to_string())?; let keybind: Vec<String> = serde_json::from_str(&json).map_err(|e| e.to_string())?;
Ok(setting.keybind) Ok(keybind)
}, }
None => Ok(vec!["Meta".to_string(), "V".to_string()]), None => {
let default_keybind = vec!["Meta".to_string(), "V".to_string()];
Ok(default_keybind)
}
} }
} }

View file

@ -1,44 +1,104 @@
use crate::api::database::get_keybind; use crate::api::database::get_keybind;
use crate::utils::commands::center_window_on_current_monitor; use crate::utils::commands::center_window_on_current_monitor;
use rdev::{listen, EventType, Key}; use global_hotkey::{
use tauri::Manager; hotkey::{Code, HotKey, Modifiers},
GlobalHotKeyEvent, GlobalHotKeyManager,
};
use std::str::FromStr;
use tauri::{AppHandle, Listener, Manager};
fn key_to_string(key: &Key) -> String { pub fn setup(app_handle: tauri::AppHandle) {
format!("{:?}", key) let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
match get_keybind(app_handle_clone.clone()).await {
Ok(keybind) => {
if !keybind.is_empty() {
let keybind_str = keybind.join("+");
println!("Keybind: {:?}", keybind_str);
if let Err(e) = register_shortcut(&app_handle_clone, &keybind_str) {
eprintln!("Error registering shortcut: {:?}", e);
}
}
}
Err(e) => {
eprintln!("Error getting keybind: {:?}", e);
}
}
});
let app_handle_for_listener = app_handle.clone();
app_handle.listen("update-shortcut", move |event| {
let payload_str = event.payload().to_string();
if let Err(e) = register_shortcut(&app_handle_for_listener, &payload_str) {
eprintln!("Error re-registering shortcut: {:?}", e);
}
});
let app_handle_for_hotkey = app_handle.clone();
tauri::async_runtime::spawn(async move {
loop {
if let Ok(_) = GlobalHotKeyEvent::receiver().recv() {
handle_hotkey_event(&app_handle_for_hotkey);
}
}
});
} }
#[warn(dead_code)] fn register_shortcut(
pub fn setup(app_handle: tauri::AppHandle) { _app_handle: &tauri::AppHandle,
std::thread::spawn(move || { shortcut: &str,
let keybind = tauri::async_runtime::block_on(async { get_keybind(app_handle.clone()).await.unwrap_or_default() }); ) -> Result<(), Box<dyn std::error::Error>> {
let manager = GlobalHotKeyManager::new()?;
let hotkey = parse_hotkey(shortcut)?;
manager.register(hotkey)?;
println!("Listening for keybind: {:?}", keybind); println!("Listening for keybind: {}", shortcut);
Ok(())
}
let mut pressed_keys = vec![false; keybind.len()]; fn parse_hotkey(shortcut: &str) -> Result<HotKey, Box<dyn std::error::Error>> {
let mut modifiers = Modifiers::empty();
let mut code = None;
listen(move |event| { for part in shortcut.split('+') {
match event.event_type { let part = part;
EventType::KeyPress(key) => { if part.to_lowercase().starts_with("ctrl") || part.to_lowercase().starts_with("control") {
if let Some(index) = keybind.iter().position(|k| k == &key_to_string(&key)) { modifiers |= Modifiers::CONTROL;
pressed_keys[index] = true; } else if part.to_lowercase().starts_with("alt") {
modifiers |= Modifiers::ALT;
} else if part.to_lowercase().starts_with("shift") {
modifiers |= Modifiers::SHIFT;
} else if part.to_lowercase().starts_with("super") || part.to_lowercase().starts_with("meta") || part.to_lowercase().starts_with("cmd") {
modifiers |= Modifiers::META;
} else {
let pascal_case_key = part
.split(|c: char| !c.is_alphanumeric())
.map(|word| {
let mut chars = word.chars();
let first_char = chars.next().unwrap().to_uppercase().collect::<String>();
let rest = chars.as_str();
first_char + rest
})
.collect::<String>();
code = Some(
Code::from_str(&pascal_case_key)
.map_err(|_| format!("Invalid key: {}", pascal_case_key))?,
);
} }
} }
EventType::KeyRelease(key) => {
if let Some(index) = keybind.iter().position(|k| k == &key_to_string(&key)) {
pressed_keys[index] = false;
}
}
_ => {}
}
if pressed_keys.iter().all(|&k| k) { Ok(HotKey::new(Some(modifiers), code.unwrap()))
pressed_keys.iter_mut().for_each(|k| *k = false); }
fn handle_hotkey_event(app_handle: &AppHandle) {
let window = app_handle.get_webview_window("main").unwrap(); let window = app_handle.get_webview_window("main").unwrap();
if window.is_visible().unwrap() {
window.hide().unwrap();
} else {
window.show().unwrap(); window.show().unwrap();
window.set_focus().unwrap(); window.set_focus().unwrap();
center_window_on_current_monitor(&window); center_window_on_current_monitor(&window);
} }
})
.unwrap();
});
} }

View file

@ -1,6 +1,6 @@
{ {
"productName": "Qopy", "productName": "Qopy",
"version": "0.1.1", "version": "0.2.0",
"identifier": "net.pandadev.qopy", "identifier": "net.pandadev.qopy",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",