Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 29 additions & 19 deletions src-tauri/src/shortcut/handy_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,22 +260,11 @@ impl HandyKeysState {
}

/// Start recording mode for a specific binding
pub fn start_recording(&self, app: &AppHandle, binding_id: String) -> Result<(), String> {
pub fn start_recording(&self, _app: &AppHandle, binding_id: String) -> Result<(), String> {
if self.is_recording.load(Ordering::SeqCst) {
return Err("Already recording".into());
}

// Create a new keyboard listener for recording
let listener = KeyboardListener::new()
.map_err(|e| format!("Failed to create keyboard listener: {}", e))?;

{
let mut recording = self
.recording_listener
.lock()
.map_err(|_| "Failed to lock recording_listener")?;
*recording = Some(listener);
}
{
let mut binding = self
.recording_binding_id
Expand All @@ -285,14 +274,35 @@ impl HandyKeysState {
}

self.is_recording.store(true, Ordering::SeqCst);
self.recording_running.store(true, Ordering::SeqCst);

// Start a thread to emit key events to the frontend
let app_clone = app.clone();
let recording_running = Arc::clone(&self.recording_running);
thread::spawn(move || {
Self::recording_loop(app_clone, recording_running);
});
// On Linux, rdev::grab() cannot run concurrently with the HotkeyManager's
// existing grab — a second grab attempt fails and retries every 2 seconds,
// causing periodic system freezes and no key events being captured. Key
// recording on Linux is handled via DOM keyboard events in the frontend.
// (Same reasoning as register_cancel_shortcut being disabled on Linux.)
#[cfg(not(target_os = "linux"))]
{
// Create a new keyboard listener for recording
let listener = KeyboardListener::new()
.map_err(|e| format!("Failed to create keyboard listener: {}", e))?;

{
let mut recording = self
.recording_listener
.lock()
.map_err(|_| "Failed to lock recording_listener")?;
*recording = Some(listener);
}

self.recording_running.store(true, Ordering::SeqCst);

// Start a thread to emit key events to the frontend
let app_clone = _app.clone();
let recording_running = Arc::clone(&self.recording_running);
thread::spawn(move || {
Self::recording_loop(app_clone, recording_running);
});
}

debug!("Started handy-keys recording mode");
Ok(())
Expand Down
89 changes: 88 additions & 1 deletion src/components/settings/HandyKeysShortcutInput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { listen } from "@tauri-apps/api/event";
import { formatKeyCombination } from "../../lib/utils/keyboard";
import { formatKeyCombination, getKeyName } from "../../lib/utils/keyboard";
import { ResetButton } from "../ui/ResetButton";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
Expand Down Expand Up @@ -152,6 +152,93 @@ export const HandyKeysShortcutInput: React.FC<HandyKeysShortcutInputProps> = ({
t,
]);

// On Linux, rdev::grab() conflicts with HotkeyManager — use DOM keyboard events
// for recording instead of the backend handy-keys-event stream.
useEffect(() => {
if (!isRecording || osType !== "linux") return;

const MODIFIER_CODES = new Set([
"ControlLeft",
"ControlRight",
"AltLeft",
"AltRight",
"ShiftLeft",
"ShiftRight",
"MetaLeft",
"MetaRight",
"OSLeft",
"OSRight",
]);

const buildHotkeyString = (e: KeyboardEvent): string | null => {
// Let the Escape handler in the other effect deal with cancellation
if (e.code === "Escape") return null;
// Don't emit modifier-only presses as a committable shortcut
if (MODIFIER_CODES.has(e.code)) return null;

const parts: string[] = [];
if (e.ctrlKey) parts.push("ctrl");
if (e.altKey) parts.push("alt");
if (e.shiftKey) parts.push("shift");
if (e.metaKey) parts.push("super");

const key = getKeyName(e, osType);
if (!key || ["ctrl", "alt", "shift", "super"].includes(key)) return null;
parts.push(key);
return parts.join("+");
};

const handleKeyDown = (e: KeyboardEvent) => {
const hotkey = buildHotkeyString(e);
if (hotkey) {
e.preventDefault();
currentKeysRef.current = hotkey;
setCurrentKeys(hotkey);
}
};

const handleKeyUp = async (e: KeyboardEvent) => {
if (!currentKeysRef.current || MODIFIER_CODES.has(e.code)) return;
// Escape is handled separately (cancels recording)
if (e.code === "Escape") return;

e.preventDefault();
const keysToCommit = currentKeysRef.current;

try {
await updateBinding(shortcutId, keysToCommit);
} catch (error) {
console.error("Failed to change binding:", error);
toast.error(
t("settings.general.shortcut.errors.set", {
error: String(error),
}),
);
if (originalBinding) {
try {
await updateBinding(shortcutId, originalBinding);
} catch (resetError) {
console.error("Failed to reset binding:", resetError);
toast.error(t("settings.general.shortcut.errors.reset"));
}
}
}

await commands.stopHandyKeysRecording().catch(console.error);
setIsRecording(false);
setCurrentKeys("");
currentKeysRef.current = "";
setOriginalBinding("");
};

window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [isRecording, osType, shortcutId, originalBinding, updateBinding, t]);

// Handle click outside
useEffect(() => {
if (!isRecording) return;
Expand Down