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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
47 changes: 37 additions & 10 deletions src-tauri/src/keyboard_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
// 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" => {
Expand All @@ -842,12 +843,28 @@ fn webview_key_to_accelerator(key: &str, code: &str) -> Option<String> {
"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()?;
Expand All @@ -858,23 +875,33 @@ fn webview_key_to_accelerator(key: &str, code: &str) -> Option<String> {

match key {
" " => Some("Space".to_string()),
"+" => Some("Plus".to_string()),
"-" => Some("Minus".to_string()),
_ => None,
}
}

/// 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()
Expand Down
20 changes: 20 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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),
Expand Down
3 changes: 2 additions & 1 deletion src/lib/stores/shortcuts.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,15 @@ 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();
}

return null;
}


/**
* Format an accelerator string for human-readable display
*
Expand Down
31 changes: 31 additions & 0 deletions src/lib/windows/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down