diff --git a/package.json b/package.json index d6f41b0..094f598 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thoth-tauri", - "version": "2026.2.7", + "version": "2026.2.21", "private": true, "description": "Privacy-first, offline-capable voice transcription application", "type": "module", @@ -25,13 +25,13 @@ }, "devDependencies": { "@sveltejs/adapter-static": "3.0.10", - "@sveltejs/kit": "2.50.0", + "@sveltejs/kit": "^2.55.0", "@sveltejs/vite-plugin-svelte": "6.2.4", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/cli": "2.9.6", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.4.1", - "svelte": "5.47.0", + "svelte": "^5.54.0", "svelte-check": "4.3.5", "tailwindcss": "^4.1.18", "typescript": "5.9.3", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9a494d4..62cbb89 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thoth" -version = "2026.2.7" +version = "2026.2.21" description = "Privacy-first, offline-capable voice transcription application" authors = ["Paul Roberts"] license = "MIT" diff --git a/src-tauri/src/keyboard_service.rs b/src-tauri/src/keyboard_service.rs index 975aff8..6bef3ee 100644 --- a/src-tauri/src/keyboard_service.rs +++ b/src-tauri/src/keyboard_service.rs @@ -823,6 +823,7 @@ fn process_webview_keydown( /// Convert webview key/code to Tauri accelerator format fn webview_key_to_accelerator(key: &str, code: &str) -> Option { + // Named codes (unambiguous regardless of shift state) match code { "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11" | "F12" | "F13" | "F14" | "F15" | "F16" | "F17" | "F18" | "F19" | "F20" => { @@ -842,12 +843,28 @@ fn webview_key_to_accelerator(key: &str, code: &str) -> Option { "ArrowDown" => return Some("Down".to_string()), "ArrowLeft" => return Some("Left".to_string()), "ArrowRight" => return Some("Right".to_string()), + "Escape" => return Some("Escape".to_string()), + // Punctuation / symbol keys — use the physical key code so the + // accelerator is layout-independent (e.g. Backquote always means `, + // regardless of whether Shift is held to produce ~). + "Backquote" => return Some("`".to_string()), + "Minus" => return Some("-".to_string()), + "Equal" => return Some("=".to_string()), + "BracketLeft" => return Some("[".to_string()), + "BracketRight" => return Some("]".to_string()), + "Backslash" => return Some("\\".to_string()), + "Semicolon" => return Some(";".to_string()), + "Quote" => return Some("'".to_string()), + "Comma" => return Some(",".to_string()), + "Period" => return Some(".".to_string()), + "Slash" => return Some("/".to_string()), // Skip pure modifiers "ControlLeft" | "ControlRight" | "ShiftLeft" | "ShiftRight" | "AltLeft" | "AltRight" | "MetaLeft" | "MetaRight" => return None, _ => {} } + // Letters and digits let key_upper = key.to_uppercase(); if key_upper.len() == 1 { let c = key_upper.chars().next()?; @@ -858,8 +875,6 @@ fn webview_key_to_accelerator(key: &str, code: &str) -> Option { match key { " " => Some("Space".to_string()), - "+" => Some("Plus".to_string()), - "-" => Some("Minus".to_string()), _ => None, } } @@ -867,14 +882,26 @@ fn webview_key_to_accelerator(key: &str, code: &str) -> Option { /// Convert webview key/code to display string fn webview_key_to_display(key: &str, code: &str) -> String { match code { - "ArrowUp" => "↑".to_string(), - "ArrowDown" => "↓".to_string(), - "ArrowLeft" => "←".to_string(), - "ArrowRight" => "→".to_string(), - "Space" => "Space".to_string(), - "Enter" => "Return".to_string(), - "Backspace" => "⌫".to_string(), - "Tab" => "⇥".to_string(), + "ArrowUp" => "↑".to_string(), + "ArrowDown" => "↓".to_string(), + "ArrowLeft" => "←".to_string(), + "ArrowRight" => "→".to_string(), + "Space" => "Space".to_string(), + "Enter" => "Return".to_string(), + "Backspace" => "⌫".to_string(), + "Tab" => "⇥".to_string(), + "Escape" => "Esc".to_string(), + "Backquote" => "`".to_string(), + "Minus" => "-".to_string(), + "Equal" => "=".to_string(), + "BracketLeft" => "[".to_string(), + "BracketRight" => "]".to_string(), + "Backslash" => "\\".to_string(), + "Semicolon" => ";".to_string(), + "Quote" => "'".to_string(), + "Comma" => ",".to_string(), + "Period" => ".".to_string(), + "Slash" => "/".to_string(), _ => { if key.len() == 1 { key.to_uppercase() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cbc0969..1c91cb7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -117,6 +117,26 @@ fn register_shortcuts_from_config(app: &tauri::AppHandle, cfg: &config::Config) .filter(|(_, accel, _)| !accel.is_empty()) .collect(); + // Deduplicate: if two shortcuts share the same accelerator, only the first wins. + // Without this, the second registration attempt fails and can corrupt the first + // registration, leaving all shortcuts broken. + let mut seen_accels: std::collections::HashSet = std::collections::HashSet::new(); + let shortcuts: Vec<(&str, &str, &str)> = shortcuts + .into_iter() + .filter(|(id, accel, _)| { + if seen_accels.contains(*accel) { + tracing::warn!( + "Skipping duplicate shortcut '{}' for '{}' — accelerator already registered by another shortcut", + id, accel + ); + false + } else { + seen_accels.insert(accel.to_string()); + true + } + }) + .collect(); + for (id, accelerator, description) in shortcuts { match register_single_shortcut(app, id, accelerator, description) { Ok(()) => tracing::info!("Registered {} shortcut: {}", id, accelerator), diff --git a/src/lib/stores/shortcuts.svelte.ts b/src/lib/stores/shortcuts.svelte.ts index 917826a..f3b930e 100644 --- a/src/lib/stores/shortcuts.svelte.ts +++ b/src/lib/stores/shortcuts.svelte.ts @@ -184,7 +184,7 @@ function normaliseKeyName(event: KeyboardEvent): string | null { return specialKeyMap[code]; } - // Fallback to key value for printable characters + // Letters and digits if (key.length === 1 && /^[a-zA-Z0-9]$/.test(key)) { return key.toUpperCase(); } @@ -192,6 +192,7 @@ function normaliseKeyName(event: KeyboardEvent): string | null { return null; } + /** * Format an accelerator string for human-readable display * diff --git a/src/lib/windows/Settings.svelte b/src/lib/windows/Settings.svelte index e6fb773..758b9c2 100644 --- a/src/lib/windows/Settings.svelte +++ b/src/lib/windows/Settings.svelte @@ -106,6 +106,17 @@ } async function handleShortcutChange(shortcut: ShortcutInfo, newAccelerator: string) { + // Guard: check if this key is already assigned to a different shortcut. + // Assigning the same key to two shortcuts breaks both — the OS only allows + // one registration per accelerator. + const conflict = findShortcutConflict(shortcut.id, newAccelerator); + if (conflict) { + alert( + `"${newAccelerator}" is already used by "${conflict}". Please choose a different key.` + ); + return; + } + // Update in-memory config, then save directly via set_shortcut_config // which bypasses the preservation logic in set_config. This ensures // the shortcut value is saved even when it matches the default. @@ -120,6 +131,26 @@ setTimeout(() => shortcutsStore.loadRegistered(), 100); } + /** + * Check if a given accelerator is already used by another shortcut. + * Returns the description of the conflicting shortcut, or null if free. + */ + function findShortcutConflict(currentId: string, accelerator: string): string | null { + const shortcuts = configStore.shortcuts; + const map: { id: string; label: string; value: string | null }[] = [ + { id: 'toggle_recording', label: 'Toggle Recording', value: shortcuts.toggleRecording }, + { id: 'toggle_recording_alt', label: 'Toggle Recording (Alt)', value: shortcuts.toggleRecordingAlt }, + { id: 'copy_last', label: 'Copy Last Transcription', value: shortcuts.copyLast }, + { id: 'add_to_dictionary', label: 'Quick-Add to Dictionary', value: shortcuts.addToDictionary }, + ]; + for (const s of map) { + if (s.id !== currentId && s.value && s.value === accelerator) { + return s.label; + } + } + return null; + } + async function handleShortcutClear(shortcut: ShortcutInfo) { // Clear from config using bypass, then re-register all shortcuts updateShortcutConfig(shortcut.id, null);