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
35 changes: 35 additions & 0 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ pub enum RecordingRetentionPeriod {
pub enum KeyboardImplementation {
Tauri,
HandyKeys,
None,
}

impl Default for KeyboardImplementation {
Expand Down Expand Up @@ -986,4 +987,38 @@ mod tests {
assert!(!out.contains("secret"));
assert!(out.contains("[REDACTED]"));
}

/// Serialize default settings, apply a JSON override, and deserialize back.
/// This mirrors real-world settings persistence where missing fields get defaults.
fn settings_with_keyboard_override(override_json: &str) -> AppSettings {
let defaults = get_default_settings();
let mut value: serde_json::Value = serde_json::to_value(&defaults).unwrap();
let overrides: serde_json::Value = serde_json::from_str(override_json).unwrap();
if let (serde_json::Value::Object(ref mut map), serde_json::Value::Object(ovr)) =
(&mut value, overrides)
{
for (k, v) in ovr {
map.insert(k, v);
}
}
serde_json::from_value(value).unwrap()
}

#[test]
fn keyboard_implementation_none_deserializes() {
let settings = settings_with_keyboard_override(r#"{"keyboard_implementation": "none"}"#);
assert_eq!(
settings.keyboard_implementation,
KeyboardImplementation::None
);
}

#[test]
fn keyboard_implementation_default_is_not_none() {
let settings = get_default_settings();
assert_ne!(
settings.keyboard_implementation,
KeyboardImplementation::None
);
}
}
49 changes: 49 additions & 0 deletions src-tauri/src/shortcut/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ pub fn init_shortcuts(app: &AppHandle) {
tauri_impl::init_shortcuts(app);
}
}
KeyboardImplementation::None => {
info!("Keyboard shortcuts disabled — use compositor keybindings with --toggle-transcription");
}
}
}

Expand All @@ -62,6 +65,7 @@ pub fn register_cancel_shortcut(app: &AppHandle) {
match settings.keyboard_implementation {
KeyboardImplementation::Tauri => tauri_impl::register_cancel_shortcut(app),
KeyboardImplementation::HandyKeys => handy_keys::register_cancel_shortcut(app),
KeyboardImplementation::None => {}
}
}

Expand All @@ -71,6 +75,7 @@ pub fn unregister_cancel_shortcut(app: &AppHandle) {
match settings.keyboard_implementation {
KeyboardImplementation::Tauri => tauri_impl::unregister_cancel_shortcut(app),
KeyboardImplementation::HandyKeys => handy_keys::unregister_cancel_shortcut(app),
KeyboardImplementation::None => {}
}
}

Expand All @@ -80,6 +85,7 @@ pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<()
match settings.keyboard_implementation {
KeyboardImplementation::Tauri => tauri_impl::register_shortcut(app, binding),
KeyboardImplementation::HandyKeys => handy_keys::register_shortcut(app, binding),
KeyboardImplementation::None => Ok(()),
}
}

Expand All @@ -89,6 +95,7 @@ pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<
match settings.keyboard_implementation {
KeyboardImplementation::Tauri => tauri_impl::unregister_shortcut(app, binding),
KeyboardImplementation::HandyKeys => handy_keys::unregister_shortcut(app, binding),
KeyboardImplementation::None => Ok(()),
}
}

Expand Down Expand Up @@ -322,6 +329,7 @@ pub fn get_keyboard_implementation(app: AppHandle) -> String {
match settings.keyboard_implementation {
KeyboardImplementation::Tauri => "tauri".to_string(),
KeyboardImplementation::HandyKeys => "handy_keys".to_string(),
KeyboardImplementation::None => "none".to_string(),
}
}

Expand All @@ -337,6 +345,7 @@ fn validate_shortcut_for_implementation(
match implementation {
KeyboardImplementation::Tauri => tauri_impl::validate_shortcut(raw),
KeyboardImplementation::HandyKeys => handy_keys::validate_shortcut(raw),
KeyboardImplementation::None => Ok(()),
}
}

Expand All @@ -345,6 +354,7 @@ fn parse_keyboard_implementation(s: &str) -> KeyboardImplementation {
match s {
"tauri" => KeyboardImplementation::Tauri,
"handy_keys" => KeyboardImplementation::HandyKeys,
"none" => KeyboardImplementation::None,
other => {
warn!(
"Invalid keyboard implementation '{}', defaulting to tauri",
Expand All @@ -368,6 +378,7 @@ fn unregister_all_shortcuts(app: &AppHandle, implementation: KeyboardImplementat
let result = match implementation {
KeyboardImplementation::Tauri => tauri_impl::unregister_shortcut(app, binding),
KeyboardImplementation::HandyKeys => handy_keys::unregister_shortcut(app, binding),
KeyboardImplementation::None => Ok(()),
};

if let Err(e) = result {
Expand Down Expand Up @@ -426,6 +437,7 @@ fn register_all_shortcuts_for_implementation(
let result = match implementation {
KeyboardImplementation::Tauri => tauri_impl::register_shortcut(app, binding),
KeyboardImplementation::HandyKeys => handy_keys::register_shortcut(app, binding),
KeyboardImplementation::None => Ok(()),
};

if let Err(e) = result {
Expand Down Expand Up @@ -1155,3 +1167,40 @@ pub async fn get_available_accelerators() -> crate::managers::transcription::Ava
.await
.expect("get_available_accelerators panicked")
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parse_keyboard_implementation_none() {
assert_eq!(
parse_keyboard_implementation("none"),
KeyboardImplementation::None
);
}

#[test]
fn parse_keyboard_implementation_tauri() {
assert_eq!(
parse_keyboard_implementation("tauri"),
KeyboardImplementation::Tauri
);
}

#[test]
fn parse_keyboard_implementation_handy_keys() {
assert_eq!(
parse_keyboard_implementation("handy_keys"),
KeyboardImplementation::HandyKeys
);
}

#[test]
fn parse_keyboard_implementation_invalid_defaults_to_tauri() {
assert_eq!(
parse_keyboard_implementation("garbage"),
KeyboardImplementation::Tauri
);
}
}
2 changes: 1 addition & 1 deletion src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ export type ImplementationChangeResult = { success: boolean;
* List of binding IDs that were reset to defaults due to incompatibility
*/
reset_bindings: string[] }
export type KeyboardImplementation = "tauri" | "handy_keys"
export type KeyboardImplementation = "tauri" | "handy_keys" | "none"
export type LLMPrompt = { id: string; name: string; prompt: string }
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error"
export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; sha256: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number; supports_translation: boolean; is_recommended: boolean; supported_languages: string[]; supports_language_selection: boolean; is_custom: boolean }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { type } from "@tauri-apps/plugin-os";
import { SettingContainer } from "../../ui/SettingContainer";
import { Dropdown, type DropdownOption } from "../../ui/Dropdown";
import { useSettings } from "../../../hooks/useSettings";
import { commands } from "@/bindings";
import { toast } from "sonner";

// "None" is Linux-only: Tauri + Handy Keys both work reliably on macOS and Windows,
// so exposing an option that disables all built-in hotkeys there would be a footgun.
// On Linux (particularly Wayland) the compositor-keybind workflow is a legitimate fallback.
const isLinux = type() === "linux";

const KEYBOARD_IMPLEMENTATION_OPTIONS: DropdownOption[] = [
{ value: "tauri", label: "Tauri Global Shortcut" },
{ value: "handy_keys", label: "Handy Keys" },
...(isLinux
? [{ value: "none", label: "None (Compositor Only)" } as DropdownOption]
: []),
];

interface KeyboardImplementationSelectorProps {
Expand Down
26 changes: 18 additions & 8 deletions src/components/settings/general/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,27 @@ export const GeneralSettings: React.FC = () => {
const { t } = useTranslation();
const { audioFeedbackEnabled, getSetting } = useSettings();
const pushToTalk = getSetting("push_to_talk");
const keyboardImpl = getSetting("keyboard_implementation") ?? "tauri";
const shortcutsDisabled = keyboardImpl === "none";
const isLinux = type() === "linux";
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title={t("settings.general.title")}>
<ShortcutInput shortcutId="transcribe" grouped={true} />
<PushToTalk descriptionMode="tooltip" grouped={true} />
{/* Cancel shortcut is hidden with push-to-talk (release key cancels) and on Linux (dynamic shortcut instability) */}
{!isLinux && !pushToTalk && (
<ShortcutInput shortcutId="cancel" grouped={true} />
)}
</SettingsGroup>
{shortcutsDisabled ? (
<SettingsGroup title={t("settings.general.title")}>
<div className="px-4 py-3 text-sm text-mid-gray">
{t("settings.general.shortcutsDisabledHint")}
</div>
</SettingsGroup>
) : (
<SettingsGroup title={t("settings.general.title")}>
<ShortcutInput shortcutId="transcribe" grouped={true} />
<PushToTalk descriptionMode="tooltip" grouped={true} />
{/* Cancel shortcut is hidden with push-to-talk (release key cancels) and on Linux (dynamic shortcut instability) */}
{!isLinux && !pushToTalk && (
<ShortcutInput shortcutId="cancel" grouped={true} />
)}
</SettingsGroup>
)}
<ModelSettingsCard />
<SettingsGroup title={t("settings.sound.title")}>
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ar/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@
"pushToTalk": {
"label": "اضغط للتحدث",
"description": "استمر في الضغط للتسجيل، واترك للتوقف"
}
},
"shortcutsDisabledHint": "مفاتيح الاختصار المدمجة معطلة. قم بتكوين مدير النوافذ لتشغيل 'handy --toggle-transcription' عند ضغطة مفتاح بدلاً من ذلك (مثال على Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). يمكنك تغيير هذا في الإعدادات > متقدم > تجريبي > تنفيذ لوحة المفاتيح."
},
"sound": {
"title": "الصوت",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/bg/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@
"pushToTalk": {
"label": "Натискане за говорене",
"description": "Задръжте за запис, пуснете за спиране"
}
},
"shortcutsDisabledHint": "Вградените клавишни комбинации са изключени. Конфигурирайте вашия композитор да стартира 'handy --toggle-transcription' с клавишна комбинация (напр. Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). Можете да промените това в Настройки > Разширени > Експериментални > Реализация на клавиатурата."
},
"models": {
"title": "Модели за транскрипция",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/cs/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@
"pushToTalk": {
"label": "Stisk a mluv",
"description": "Podržte pro nahrávání, uvolněte pro zastavení"
}
},
"shortcutsDisabledHint": "Vestavěné klávesové zkratky jsou zakázány. Místo toho nakonfigurujte kompozitor, aby spouštěl 'handy --toggle-transcription' na klávesovou zkratku (např. Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). Toto můžete změnit v Nastavení > Pokročilé > Experimentální > Implementace klávesnice."
},
"sound": {
"title": "Zvuk",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@
"pushToTalk": {
"label": "Push-to-Talk",
"description": "Gedrückt halten zum Aufnehmen, loslassen zum Stoppen"
}
},
"shortcutsDisabledHint": "Integrierte Tastenkombinationen sind deaktiviert. Konfiguriere stattdessen deinen Compositor, um 'handy --toggle-transcription' über eine Tastenkombination auszuführen (z. B. Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). Du kannst dies unter Einstellungen > Erweitert > Experimentell > Tastatur-Implementierung ändern."
},
"sound": {
"title": "Ton",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
},
"general": {
"title": "General",
"shortcutsDisabledHint": "Built-in hotkeys are disabled. Configure your compositor to run 'handy --toggle-transcription' on a keybind instead (e.g. Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). You can change this in Settings > Advanced > Experimental > Keyboard Implementation.",
"shortcut": {
"title": "Handy Shortcuts",
"description": "Configure keyboard shortcuts to trigger speech-to-text recording",
Expand Down Expand Up @@ -492,7 +493,7 @@
},
"keyboardImplementation": {
"title": "Keyboard Implementation",
"description": "Choose the keyboard shortcut backend.",
"description": "Choose the keyboard shortcut backend. Select 'None' on Wayland to disable built-in hotkeys and use compositor keybindings instead (e.g. handy --toggle-transcription).",
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
},
"paths": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@
"pushToTalk": {
"label": "Presionar para Hablar",
"description": "Mantén presionado para grabar, suelta para detener"
}
},
"shortcutsDisabledHint": "Los atajos de teclado integrados están desactivados. Configura tu compositor para ejecutar 'handy --toggle-transcription' con una combinación de teclas (p. ej. Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). Puedes cambiar esto en Ajustes > Avanzado > Experimental > Implementación del teclado."
},
"sound": {
"title": "Sonido",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@
"pushToTalk": {
"label": "Appuyer pour parler",
"description": "Maintenez pour enregistrer, relâchez pour arrêter"
}
},
"shortcutsDisabledHint": "Les raccourcis clavier intégrés sont désactivés. Configurez plutôt votre compositeur pour exécuter 'handy --toggle-transcription' sur une combinaison de touches (par exemple Hyprland : bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). Vous pouvez modifier ceci dans Paramètres > Avancé > Expérimental > Gestion du clavier."
},
"sound": {
"title": "Son",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/he/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@
"pushToTalk": {
"label": "לחץ כדי לדבר",
"description": "החזק כדי להקליט, שחרר כדי לעצור"
}
},
"shortcutsDisabledHint": "מקשי קיצור מובנים מושבתים. הגדר את מנהל החלונות שלך להפעיל 'handy --toggle-transcription' באמצעות צירוף מקשים במקום (למשל Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). ניתן לשנות זאת בהגדרות > מתקדם > ניסיוני > מימוש מקלדת."
},
"models": {
"title": "מודלי תמלול",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/it/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@
"pushToTalk": {
"label": "Premi per Parlare",
"description": "Tieni premuto per parlare, rilascia per interrompere"
}
},
"shortcutsDisabledHint": "Le scorciatoie da tastiera integrate sono disabilitate. Configura invece il tuo compositor per eseguire 'handy --toggle-transcription' con una combinazione di tasti (es. Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). Puoi modificarlo in Impostazioni > Avanzate > Sperimentale > Implementazione tastiera."
},
"sound": {
"title": "Suono",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ja/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@
"pushToTalk": {
"label": "プッシュトゥトーク",
"description": "押し続けて録音、離して停止"
}
},
"shortcutsDisabledHint": "内蔵のホットキーは無効化されています。代わりにコンポジターを設定して、キーバインドで 'handy --toggle-transcription' を実行してください(例: Hyprland の場合 bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription)。これは 設定 > 詳細設定 > 実験的 > キーボード実装 で変更できます。"
},
"sound": {
"title": "サウンド",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@
"pushToTalk": {
"label": "녹음 중 단축키 홀딩",
"description": "누르고 있으면 녹음, 놓으면 정지"
}
},
"shortcutsDisabledHint": "내장 단축키가 비활성화되었습니다. 대신 컴포지터가 키 바인드에서 'handy --toggle-transcription'을 실행하도록 설정하세요 (예: Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). 이는 설정 > 고급 > 실험적 > 키보드 구현에서 변경할 수 있습니다."
},
"sound": {
"title": "사운드",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/pl/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@
"pushToTalk": {
"label": "Push To Talk",
"description": "Przytrzymaj, aby nagrywać, puść, aby zatrzymać"
}
},
"shortcutsDisabledHint": "Wbudowane skróty klawiszowe są wyłączone. Zamiast tego skonfiguruj swój kompozytor, aby uruchamiał 'handy --toggle-transcription' pod skrótem klawiszowym (np. Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). Możesz to zmienić w Ustawieniach > Zaawansowane > Eksperymentalne > Implementacja klawiatury."
},
"sound": {
"title": "Dźwięk",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/pt/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@
"pushToTalk": {
"label": "Pressionar para Falar",
"description": "Segure para gravar, solte para parar"
}
},
"shortcutsDisabledHint": "As teclas de atalho integradas estão desativadas. Em vez disso, configure seu compositor para executar 'handy --toggle-transcription' em uma combinação de teclas (por ex. Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). Você pode alterar isso em Configurações > Avançado > Experimental > Implementação do Teclado."
},
"models": {
"title": "Modelos de Transcrição",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ru/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@
"pushToTalk": {
"label": "Нажми и говори",
"description": "Удерживайте, чтобы записать, отпустите, чтобы остановить"
}
},
"shortcutsDisabledHint": "Встроенные горячие клавиши отключены. Вместо этого настройте ваш композитор на выполнение 'handy --toggle-transcription' по нажатию клавиш (например, Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). Это можно изменить в Настройки > Продвинутые > Экспериментальное > Реализация клавиатуры."
},
"sound": {
"title": "Звук",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/sv/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@
"pushToTalk": {
"label": "Tryck för att tala",
"description": "Håll ned för att spela in, släpp för att stoppa"
}
},
"shortcutsDisabledHint": "Inbyggda snabbtangenter är inaktiverade. Konfigurera istället din kompositör att köra 'handy --toggle-transcription' vid en tangentbindning (t.ex. Hyprland: bind = CTRL SHIFT, SPACE, exec, handy --toggle-transcription). Du kan ändra detta i Inställningar > Avancerat > Experimentellt > Tangentbordsimplementering."
},
"models": {
"title": "Transkriptionsmodeller",
Expand Down
Loading