diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9203b8f..6ccf061 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1105,7 +1105,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.0", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2001,6 +2001,7 @@ dependencies = [ "futures-util", "hound", "log", + "natural", "once_cell", "ort-sys", "rdev", @@ -2010,6 +2011,7 @@ dependencies = [ "rustfft", "serde", "serde_json", + "strsim", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -2962,6 +2964,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "natural" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65e6b44f8ddc659cde3555e0140d3441ad26cb03a1410774af1f9a19097c1867" +dependencies = [ + "rust-stemmers", +] + [[package]] name = "ndarray" version = "0.16.1" @@ -4365,6 +4376,16 @@ dependencies = [ "realfft", ] +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + [[package]] name = "rustc-demangle" version = "0.1.24" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9b31d84..89f25ac 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -57,6 +57,8 @@ reqwest = { version = "0.11", features = ["json", "stream"] } futures-util = "0.3" tauri-plugin-fs = "2" rustfft = "6.4.0" +strsim = "0.11.0" +natural = "0.5.0" [dependencies.ort-sys] version = "=2.0.0-rc.9" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e220fac..a7761d4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -187,6 +187,7 @@ pub fn run() { shortcut::change_selected_language_setting, shortcut::change_overlay_position_setting, shortcut::change_debug_mode_setting, + shortcut::update_custom_words, shortcut::suspend_binding, shortcut::resume_binding, trigger_update_check, diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index bfd3e79..51ea000 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1,8 +1,10 @@ use crate::managers::model::ModelManager; use crate::settings::get_settings; use anyhow::Result; +use natural::phonetics::soundex; use serde::Serialize; use std::sync::{Arc, Mutex}; +use strsim::levenshtein; use tauri::{App, AppHandle, Emitter, Manager}; use whisper_rs::install_whisper_log_trampoline; use whisper_rs::{ @@ -25,6 +27,114 @@ pub struct TranscriptionManager { current_model_id: Mutex>, } +fn apply_custom_words(text: &str, custom_words: &[String]) -> String { + if custom_words.is_empty() { + return text.to_string(); + } + + // Pre-compute lowercase versions to avoid repeated allocations + let custom_words_lower: Vec = custom_words.iter().map(|w| w.to_lowercase()).collect(); + + let words: Vec<&str> = text.split_whitespace().collect(); + let mut corrected_words = Vec::new(); + + for word in words { + let cleaned_word = word + .trim_matches(|c: char| !c.is_alphabetic()) + .to_lowercase(); + + if cleaned_word.is_empty() { + corrected_words.push(word.to_string()); + continue; + } + + // Skip extremely long words to avoid performance issues + if cleaned_word.len() > 50 { + corrected_words.push(word.to_string()); + continue; + } + + let mut best_match: Option<&String> = None; + let mut best_score = f64::MAX; + + for (i, custom_word_lower) in custom_words_lower.iter().enumerate() { + // Skip if lengths are too different (optimization) + let len_diff = (cleaned_word.len() as i32 - custom_word_lower.len() as i32).abs(); + if len_diff > 5 { + continue; + } + + // Calculate Levenshtein distance (normalized by length) + let levenshtein_dist = levenshtein(&cleaned_word, custom_word_lower); + let max_len = cleaned_word.len().max(custom_word_lower.len()) as f64; + let levenshtein_score = if max_len > 0.0 { + levenshtein_dist as f64 / max_len + } else { + 1.0 + }; + + // Calculate phonetic similarity using Soundex + let phonetic_match = soundex(&cleaned_word, custom_word_lower); + + // Combine scores: favor phonetic matches, but also consider string similarity + let combined_score = if phonetic_match { + levenshtein_score * 0.3 // Give significant boost to phonetic matches + } else { + levenshtein_score + }; + + // Accept if the score is good enough (threshold: 0.4 for good matches) + if combined_score < 0.4 && combined_score < best_score { + best_match = Some(&custom_words[i]); + best_score = combined_score; + } + } + + if let Some(replacement) = best_match { + // Preserve the original case pattern as much as possible + let corrected = if word.chars().all(|c| c.is_uppercase()) { + replacement.to_uppercase() + } else if word.chars().next().map_or(false, |c| c.is_uppercase()) { + let mut chars: Vec = replacement.chars().collect(); + if let Some(first_char) = chars.get_mut(0) { + *first_char = first_char.to_uppercase().next().unwrap_or(*first_char); + } + chars.into_iter().collect() + } else { + replacement.clone() + }; + + // Preserve punctuation from original word - optimized version + let prefix_end = word.chars().take_while(|c| !c.is_alphabetic()).count(); + let suffix_start = word + .char_indices() + .rev() + .take_while(|(_, c)| !c.is_alphabetic()) + .count(); + + let original_prefix = if prefix_end > 0 { + &word[..prefix_end] + } else { + "" + }; + let original_suffix = if suffix_start > 0 { + &word[word.len() - suffix_start..] + } else { + "" + }; + + corrected_words.push(format!( + "{}{}{}", + original_prefix, corrected, original_suffix + )); + } else { + corrected_words.push(word.to_string()); + } + } + + corrected_words.join(" ") +} + impl TranscriptionManager { pub fn new(app: &App, model_manager: Arc) -> Result { let app_handle = app.app_handle().clone(); @@ -207,6 +317,13 @@ impl TranscriptionManager { result.push_str(&segment); } + // Apply word correction if custom words are configured + let corrected_result = if !settings.custom_words.is_empty() { + apply_custom_words(&result, &settings.custom_words) + } else { + result + }; + let et = std::time::Instant::now(); let translation_note = if settings.translate_to_english { " (translated)" @@ -215,6 +332,6 @@ impl TranscriptionManager { }; println!("\ntook {}ms{}", (et - st).as_millis(), translation_note); - Ok(result.trim().to_string()) + Ok(corrected_result.trim().to_string()) } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 99d1520..6b08cf8 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -42,6 +42,8 @@ pub struct AppSettings { pub overlay_position: OverlayPosition, #[serde(default = "default_debug_mode")] pub debug_mode: bool, + #[serde(default)] + pub custom_words: Vec, } fn default_model() -> String { @@ -104,6 +106,7 @@ pub fn get_default_settings() -> AppSettings { selected_language: "auto".to_string(), overlay_position: OverlayPosition::Bottom, debug_mode: false, + custom_words: Vec::new(), } } diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index 189f33d..d1db091 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -148,10 +148,10 @@ pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Resu }; settings.overlay_position = parsed; settings::write_settings(&app, settings); - + // Update overlay position without recreating window crate::utils::update_overlay_position(&app); - + Ok(()) } @@ -163,6 +163,14 @@ pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), St Ok(()) } +#[tauri::command] +pub fn update_custom_words(app: AppHandle, words: Vec) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.custom_words = words; + settings::write_settings(&app, settings); + Ok(()) +} + /// Determine whether a shortcut string contains at least one non-modifier key. /// We allow single non-modifier keys (e.g. "f5" or "space") but disallow /// modifier-only combos (e.g. "ctrl" or "ctrl+shift"). diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fceaad4..97a22c4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,9 +15,9 @@ { "title": "Handy", "width": 540, - "height": 700, + "height": 730, "minWidth": 540, - "minHeight": 700, + "minHeight": 730, "resizable": true, "maximizable": false } diff --git a/src/App.css b/src/App.css index a026572..c0a659d 100644 --- a/src/App.css +++ b/src/App.css @@ -12,7 +12,7 @@ :root { /* Typography */ - font-size: 16px; + font-size: 15px; line-height: 24px; font-weight: 400; diff --git a/src/App.tsx b/src/App.tsx index fbd1a45..e62631c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,12 @@ import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; +import { Toaster } from "sonner"; import "./App.css"; import AccessibilityPermissions from "./components/AccessibilityPermissions"; import Footer from "./components/footer"; import HandyTextLogo from "./components/icons/HandyTextLogo"; import Onboarding from "./components/onboarding"; import { Settings } from "./components/settings/Settings"; -import { Toaster } from "sonner"; function App() { const [showOnboarding, setShowOnboarding] = useState(null); @@ -34,8 +34,8 @@ function App() { if (showOnboarding) { return (
-
- +
+
@@ -45,8 +45,8 @@ function App() { return (
-
- +
+
diff --git a/src/components/icons/ResetIcon.tsx b/src/components/icons/ResetIcon.tsx index 4136118..c1dbf82 100644 --- a/src/components/icons/ResetIcon.tsx +++ b/src/components/icons/ResetIcon.tsx @@ -1,15 +1,35 @@ import React from "react"; -const ResetIcon = ({ className = "" }: { className?: string }) => { +interface ResetIconProps { + width?: number; + height?: number; + color?: string; + className?: string; +} + +const ResetIcon: React.FC = ({ + width = 20, + height = 20, + className = "", +}) => { return ( - + + + + ); }; diff --git a/src/components/settings/AlwaysOnMicrophone.tsx b/src/components/settings/AlwaysOnMicrophone.tsx index c4c40c1..48fdae0 100644 --- a/src/components/settings/AlwaysOnMicrophone.tsx +++ b/src/components/settings/AlwaysOnMicrophone.tsx @@ -7,7 +7,7 @@ interface AlwaysOnMicrophoneProps { grouped?: boolean; } -export const AlwaysOnMicrophone: React.FC = ({ +export const AlwaysOnMicrophone: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false, }) => { @@ -26,4 +26,4 @@ export const AlwaysOnMicrophone: React.FC = ({ grouped={grouped} /> ); -}; +}); diff --git a/src/components/settings/AudioFeedback.tsx b/src/components/settings/AudioFeedback.tsx index dfe73e5..36524c1 100644 --- a/src/components/settings/AudioFeedback.tsx +++ b/src/components/settings/AudioFeedback.tsx @@ -7,7 +7,7 @@ interface AudioFeedbackProps { grouped?: boolean; } -export const AudioFeedback: React.FC = ({ +export const AudioFeedback: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false, }) => { @@ -26,4 +26,4 @@ export const AudioFeedback: React.FC = ({ grouped={grouped} /> ); -}; +}); diff --git a/src/components/settings/CustomWords.tsx b/src/components/settings/CustomWords.tsx new file mode 100644 index 0000000..0f059d3 --- /dev/null +++ b/src/components/settings/CustomWords.tsx @@ -0,0 +1,115 @@ +import React, { useState } from "react"; +import { useSettings } from "../../hooks/useSettings"; +import { Input } from "../ui/Input"; +import { Button } from "../ui/Button"; +import { SettingContainer } from "../ui/SettingContainer"; + +interface CustomWordsProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const CustomWords: React.FC = React.memo( + ({ descriptionMode = "tooltip", grouped = false }) => { + const { getSetting, updateSetting, isUpdating } = useSettings(); + const [newWord, setNewWord] = useState(""); + const customWords = getSetting("custom_words") || []; + + const handleAddWord = () => { + const trimmedWord = newWord.trim(); + const sanitizedWord = trimmedWord.replace(/[<>"'&]/g, ""); + if ( + sanitizedWord && + !sanitizedWord.includes(" ") && + sanitizedWord.length <= 50 && + !customWords.includes(sanitizedWord) + ) { + updateSetting("custom_words", [...customWords, sanitizedWord]); + setNewWord(""); + } + }; + + const handleRemoveWord = (wordToRemove: string) => { + updateSetting( + "custom_words", + customWords.filter((word) => word !== wordToRemove), + ); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddWord(); + } + }; + + return ( + <> + +
+ setNewWord(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="Add a word" + variant="compact" + disabled={isUpdating("custom_words")} + /> + +
+
+ {customWords.length > 0 && ( +
+ {customWords.map((word) => ( + + ))} +
+ )} + + ); + }, +); diff --git a/src/components/settings/HandyShortcut.tsx b/src/components/settings/HandyShortcut.tsx index 7d9e820..b89b87f 100644 --- a/src/components/settings/HandyShortcut.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -6,7 +6,7 @@ import { normalizeKey, type OSType, } from "../../lib/utils/keyboard"; -import ResetIcon from "../icons/ResetIcon"; +import { ResetButton } from "../ui/ResetButton"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; import { invoke } from "@tauri-apps/api/core"; @@ -69,10 +69,11 @@ export const HandyShortcut: React.FC = ({ // Only add event listeners when we're in editing mode if (editingShortcutId === null) return; - console.log("keyPressed", keyPressed); + let cleanup = false; // Keyboard event listeners const handleKeyDown = async (e: KeyboardEvent) => { + if (cleanup) return; if (e.repeat) return; // ignore auto-repeat if (e.key === "Escape") { // Cancel recording and restore original binding @@ -103,8 +104,6 @@ export const HandyShortcut: React.FC = ({ const rawKey = getKeyName(e, osType); const key = normalizeKey(rawKey); - console.log("You pressed", rawKey, "normalized to", key); - if (!keyPressed.includes(key)) { setKeyPressed((prev) => [...prev, key]); // Also add to recorded keys if not already there @@ -115,6 +114,7 @@ export const HandyShortcut: React.FC = ({ }; const handleKeyUp = async (e: KeyboardEvent) => { + if (cleanup) return; e.preventDefault(); // Get the key with OS-specific naming and normalize it @@ -166,6 +166,7 @@ export const HandyShortcut: React.FC = ({ // Add click outside handler const handleClickOutside = async (e: MouseEvent) => { + if (cleanup) return; const activeElement = shortcutRefs.current.get(editingShortcutId); if (activeElement && !activeElement.contains(e.target as Node)) { // Cancel shortcut recording and restore original binding @@ -196,6 +197,7 @@ export const HandyShortcut: React.FC = ({ window.addEventListener("click", handleClickOutside); return () => { + cleanup = true; window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("click", handleClickOutside); @@ -299,13 +301,10 @@ export const HandyShortcut: React.FC = ({ {formatKeyCombination(primaryBinding.current_binding, osType)}
)} - + />
); })()} diff --git a/src/components/settings/LanguageSelector.tsx b/src/components/settings/LanguageSelector.tsx index c29db8c..c0ec705 100644 --- a/src/components/settings/LanguageSelector.tsx +++ b/src/components/settings/LanguageSelector.tsx @@ -1,55 +1,14 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useMemo } from "react"; import { SettingContainer } from "../ui/SettingContainer"; -import ResetIcon from "../icons/ResetIcon"; +import { ResetButton } from "../ui/ResetButton"; import { useSettings } from "../../hooks/useSettings"; +import { LANGUAGES } from "../../lib/constants/languages"; interface LanguageSelectorProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } -const LANGUAGES = [ - { code: "auto", name: "Auto" }, - { code: "en", name: "English" }, - { code: "zh", name: "Chinese" }, - { code: "hi", name: "Hindi" }, - { code: "es", name: "Spanish" }, - { code: "fr", name: "French" }, - { code: "ar", name: "Arabic" }, - { code: "pt", name: "Portuguese" }, - { code: "ru", name: "Russian" }, - { code: "ja", name: "Japanese" }, - { code: "de", name: "German" }, - { code: "ko", name: "Korean" }, - { code: "it", name: "Italian" }, - { code: "tr", name: "Turkish" }, - { code: "vi", name: "Vietnamese" }, - { code: "pl", name: "Polish" }, - { code: "nl", name: "Dutch" }, - { code: "uk", name: "Ukrainian" }, - { code: "fa", name: "Persian" }, - { code: "th", name: "Thai" }, - { code: "ro", name: "Romanian" }, - { code: "el", name: "Greek" }, - { code: "cs", name: "Czech" }, - { code: "sv", name: "Swedish" }, - { code: "hu", name: "Hungarian" }, - { code: "he", name: "Hebrew" }, - { code: "da", name: "Danish" }, - { code: "fi", name: "Finnish" }, - { code: "no", name: "Norwegian" }, - { code: "sk", name: "Slovak" }, - { code: "bg", name: "Bulgarian" }, - { code: "hr", name: "Croatian" }, - { code: "lt", name: "Lithuanian" }, - { code: "sl", name: "Slovenian" }, - { code: "lv", name: "Latvian" }, - { code: "et", name: "Estonian" }, - { code: "sr", name: "Serbian" }, - { code: "is", name: "Icelandic" }, - { code: "id", name: "Indonesian" }, -]; - export const LanguageSelector: React.FC = ({ descriptionMode = "tooltip", grouped = false, @@ -85,12 +44,15 @@ export const LanguageSelector: React.FC = ({ } }, [isOpen]); - const filteredLanguages = LANGUAGES.filter((language) => - language.name.toLowerCase().includes(searchQuery.toLowerCase()), + const filteredLanguages = useMemo( + () => LANGUAGES.filter((language) => + language.label.toLowerCase().includes(searchQuery.toLowerCase()), + ), + [searchQuery] ); const selectedLanguageName = - LANGUAGES.find((lang) => lang.code === selectedLanguage)?.name || "Auto"; + LANGUAGES.find((lang) => lang.value === selectedLanguage)?.label || "Auto"; const handleLanguageSelect = async (languageCode: string) => { await updateSetting("selected_language", languageCode); @@ -114,7 +76,7 @@ export const LanguageSelector: React.FC = ({ const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && filteredLanguages.length > 0) { // Select first filtered language on Enter - handleLanguageSelect(filteredLanguages[0].code); + handleLanguageSelect(filteredLanguages[0].value); } else if (event.key === "Escape") { setIsOpen(false); setSearchQuery(""); @@ -181,17 +143,17 @@ export const LanguageSelector: React.FC = ({ ) : ( filteredLanguages.map((language) => ( )) @@ -200,13 +162,10 @@ export const LanguageSelector: React.FC = ({
)} - + /> {isUpdating("selected_language") && (
diff --git a/src/components/settings/MicrophoneSelector.tsx b/src/components/settings/MicrophoneSelector.tsx index a958719..f37ab38 100644 --- a/src/components/settings/MicrophoneSelector.tsx +++ b/src/components/settings/MicrophoneSelector.tsx @@ -1,33 +1,15 @@ import React from "react"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; -import ResetIcon from "../icons/ResetIcon"; +import { ResetButton } from "../ui/ResetButton"; import { useSettings } from "../../hooks/useSettings"; -// Simple refresh icon component -const RefreshIcon: React.FC<{ className?: string }> = ({ className = "" }) => ( - - - -); - interface MicrophoneSelectorProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } -export const MicrophoneSelector: React.FC = ({ +export const MicrophoneSelector: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false, }) => { @@ -41,18 +23,15 @@ export const MicrophoneSelector: React.FC = ({ refreshAudioDevices, } = useSettings(); - const selectedMicrophone = getSetting("selected_microphone") || "Default"; + const selectedMicrophone = getSetting("selected_microphone") === "default" ? "Default" : getSetting("selected_microphone") || "Default"; + const handleMicrophoneSelect = async (deviceName: string) => { await updateSetting("selected_microphone", deviceName); - console.log( - `Microphone changed to: ${audioDevices.find((d) => d.name === deviceName)?.name}`, - ); }; const handleReset = async () => { await resetSetting("selected_microphone"); - console.log("Microphone reset to default"); }; const microphoneOptions = audioDevices.map(device => ({ @@ -72,23 +51,15 @@ export const MicrophoneSelector: React.FC = ({ options={microphoneOptions} selectedValue={selectedMicrophone} onSelect={handleMicrophoneSelect} - placeholder={isLoading ? "Loading..." : "Select microphone..."} - disabled={isUpdating("selected_microphone") || isLoading} + placeholder={isLoading || audioDevices.length === 0 ? "Loading..." : "Select microphone..."} + disabled={isUpdating("selected_microphone") || isLoading || audioDevices.length === 0} onRefresh={refreshAudioDevices} /> - + />
- {isUpdating("selected_microphone") && ( -
-
-
- )} ); -}; +}); diff --git a/src/components/settings/OutputDeviceSelector.tsx b/src/components/settings/OutputDeviceSelector.tsx index f3b3776..b0efd70 100644 --- a/src/components/settings/OutputDeviceSelector.tsx +++ b/src/components/settings/OutputDeviceSelector.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; -import ResetIcon from "../icons/ResetIcon"; +import { ResetButton } from "../ui/ResetButton"; import { useSettings } from "../../hooks/useSettings"; interface OutputDeviceSelectorProps { @@ -9,7 +9,7 @@ interface OutputDeviceSelectorProps { grouped?: boolean; } -export const OutputDeviceSelector: React.FC = ({ +export const OutputDeviceSelector: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false, }) => { @@ -23,19 +23,14 @@ export const OutputDeviceSelector: React.FC = ({ refreshOutputDevices, } = useSettings(); - const selectedOutputDevice = - getSetting("selected_output_device") || "Default"; + const selectedOutputDevice = getSetting("selected_output_device") === "default" ? "Default" : getSetting("selected_output_device") || "Default"; const handleOutputDeviceSelect = async (deviceName: string) => { await updateSetting("selected_output_device", deviceName); - console.log( - `Output device changed to: ${outputDevices.find((d) => d.name === deviceName)?.name}`, - ); }; const handleReset = async () => { await resetSetting("selected_output_device"); - console.log("Output device reset to default"); }; const outputDeviceOptions = outputDevices.map(device => ({ @@ -55,23 +50,15 @@ export const OutputDeviceSelector: React.FC = ({ options={outputDeviceOptions} selectedValue={selectedOutputDevice} onSelect={handleOutputDeviceSelect} - placeholder={isLoading ? "Loading..." : "Select output device..."} - disabled={isUpdating("selected_output_device") || isLoading} + placeholder={isLoading || outputDevices.length === 0 ? "Loading..." : "Select output device..."} + disabled={isUpdating("selected_output_device") || isLoading || outputDevices.length === 0} onRefresh={refreshOutputDevices} /> - + /> - {isUpdating("selected_output_device") && ( -
-
-
- )} ); -}; +}); diff --git a/src/components/settings/PushToTalk.tsx b/src/components/settings/PushToTalk.tsx index 848e504..01ed019 100644 --- a/src/components/settings/PushToTalk.tsx +++ b/src/components/settings/PushToTalk.tsx @@ -7,7 +7,7 @@ interface PushToTalkProps { grouped?: boolean; } -export const PushToTalk: React.FC = ({ +export const PushToTalk: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false, }) => { @@ -26,4 +26,4 @@ export const PushToTalk: React.FC = ({ grouped={grouped} /> ); -}; +}); diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 53c3ab9..ca83a8d 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -8,6 +8,7 @@ import { ShowOverlay } from "./ShowOverlay"; import { HandyShortcut } from "./HandyShortcut"; import { TranslateToEnglish } from "./TranslateToEnglish"; import { LanguageSelector } from "./LanguageSelector"; +import { CustomWords } from "./CustomWords"; import { SettingsGroup } from "../ui/SettingsGroup"; import { DebugSettings } from "./debug"; import { useSettings } from "../../hooks/useSettings"; @@ -54,6 +55,7 @@ export const Settings: React.FC = () => { + diff --git a/src/components/settings/ShowOverlay.tsx b/src/components/settings/ShowOverlay.tsx index e177e6d..d606e74 100644 --- a/src/components/settings/ShowOverlay.tsx +++ b/src/components/settings/ShowOverlay.tsx @@ -15,7 +15,7 @@ const overlayOptions = [ { value: "top", label: "Top" }, ]; -export const ShowOverlay: React.FC = ({ +export const ShowOverlay: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false, }) => { @@ -41,4 +41,4 @@ export const ShowOverlay: React.FC = ({ /> ); -}; +}); diff --git a/src/components/settings/TranslateToEnglish.tsx b/src/components/settings/TranslateToEnglish.tsx index 92917c3..00f4599 100644 --- a/src/components/settings/TranslateToEnglish.tsx +++ b/src/components/settings/TranslateToEnglish.tsx @@ -7,7 +7,7 @@ interface TranslateToEnglishProps { grouped?: boolean; } -export const TranslateToEnglish: React.FC = ({ +export const TranslateToEnglish: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false, }) => { @@ -26,4 +26,4 @@ export const TranslateToEnglish: React.FC = ({ grouped={grouped} /> ); -}; +}); diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index e8ffd89..4838851 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -7,3 +7,4 @@ export { AudioFeedback } from "./AudioFeedback"; export { ShowOverlay } from "./ShowOverlay"; export { HandyShortcut } from "./HandyShortcut"; export { TranslateToEnglish } from "./TranslateToEnglish"; +export { CustomWords } from "./CustomWords"; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..13462ca --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "danger" | "ghost"; + size?: "sm" | "md" | "lg"; +} + +export const Button: React.FC = ({ + children, + className = "", + variant = "primary", + size = "md", + ...props +}) => { + const baseClasses = + "font-medium rounded focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"; + + const variantClasses = { + primary: + "text-white bg-background-ui hover:bg-background-ui/90 focus:ring-1 focus:ring-background-ui", + secondary: "bg-mid-gray/10 hover:bg-background-ui/30 focus:outline-none", + danger: + "text-white bg-red-600 hover:bg-red-700 focus:ring-1 focus:ring-red-500", + ghost: "text-current hover:bg-mid-gray/10 focus:bg-mid-gray/20", + }; + + const sizeClasses = { + sm: "px-2 py-1 text-xs", + md: "px-4 py-[5px] text-sm", + lg: "px-4 py-2 text-base", + }; + + return ( + + ); +}; diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index 5ffb010..8e1cf5c 100644 --- a/src/components/ui/Dropdown.tsx +++ b/src/components/ui/Dropdown.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useEffect, useRef, useState } from "react"; export interface DropdownOption { value: string; @@ -95,7 +95,7 @@ export const Dropdown: React.FC = ({ type="button" className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${ selectedValue === option.value - ? "bg-logo-primary/20 text-logo-primary font-semibold" + ? "bg-logo-primary/20 font-semibold" : "" }`} onClick={() => handleSelect(option.value)} diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx new file mode 100644 index 0000000..e1b09cd --- /dev/null +++ b/src/components/ui/Input.tsx @@ -0,0 +1,25 @@ +import React from "react"; + +interface InputProps extends React.InputHTMLAttributes { + variant?: "default" | "compact"; +} + +export const Input: React.FC = ({ + className = "", + variant = "default", + ...props +}) => { + const baseClasses = "px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded text-left flex items-center justify-between transition-all duration-150 hover:bg-logo-primary/10 hover:border-logo-primary focus:outline-none focus:bg-logo-primary/20 focus:border-logo-primary"; + + const variantClasses = { + default: "px-3 py-2", + compact: "px-2 py-1" + }; + + return ( + + ); +}; \ No newline at end of file diff --git a/src/components/ui/ResetButton.tsx b/src/components/ui/ResetButton.tsx new file mode 100644 index 0000000..4f8a7d3 --- /dev/null +++ b/src/components/ui/ResetButton.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import ResetIcon from "../icons/ResetIcon"; + +interface ResetButtonProps { + onClick: () => void; + disabled?: boolean; + className?: string; +} + +export const ResetButton: React.FC = React.memo( + ({ onClick, disabled = false, className = "" }) => ( + + ), +); diff --git a/src/components/ui/SettingContainer.tsx b/src/components/ui/SettingContainer.tsx index 01d06d5..a50f598 100644 --- a/src/components/ui/SettingContainer.tsx +++ b/src/components/ui/SettingContainer.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; interface SettingContainerProps { title: string; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 17588bc..7b64811 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -200,11 +200,16 @@ export const useSettings = (): UseSettingsReturn => { }); break; case "overlay_position": - await invoke("change_overlay_position_setting", { position: value }); + await invoke("change_overlay_position_setting", { + position: value, + }); break; case "debug_mode": await invoke("change_debug_mode_setting", { enabled: value }); break; + case "custom_words": + await invoke("update_custom_words", { words: value }); + break; case "bindings": // Handle bindings separately - they use their own invoke methods break; @@ -251,6 +256,7 @@ export const useSettings = (): UseSettingsReturn => { selected_language: "auto", overlay_position: "bottom", debug_mode: false, + custom_words: [], }; const defaultValue = defaults[key]; diff --git a/src/lib/constants/languages.ts b/src/lib/constants/languages.ts new file mode 100644 index 0000000..8e661fd --- /dev/null +++ b/src/lib/constants/languages.ts @@ -0,0 +1,59 @@ +export interface Language { + value: string; + label: string; +} + +export const LANGUAGES: Language[] = [ + { value: "auto", label: "Auto Detect" }, + { value: "en", label: "English" }, + { value: "zh", label: "Chinese" }, + { value: "de", label: "German" }, + { value: "es", label: "Spanish" }, + { value: "ru", label: "Russian" }, + { value: "ko", label: "Korean" }, + { value: "fr", label: "French" }, + { value: "ja", label: "Japanese" }, + { value: "pt", label: "Portuguese" }, + { value: "tr", label: "Turkish" }, + { value: "pl", label: "Polish" }, + { value: "ca", label: "Catalan" }, + { value: "nl", label: "Dutch" }, + { value: "ar", label: "Arabic" }, + { value: "sv", label: "Swedish" }, + { value: "it", label: "Italian" }, + { value: "id", label: "Indonesian" }, + { value: "hi", label: "Hindi" }, + { value: "fi", label: "Finnish" }, + { value: "vi", label: "Vietnamese" }, + { value: "he", label: "Hebrew" }, + { value: "uk", label: "Ukrainian" }, + { value: "el", label: "Greek" }, + { value: "ms", label: "Malay" }, + { value: "cs", label: "Czech" }, + { value: "ro", label: "Romanian" }, + { value: "da", label: "Danish" }, + { value: "hu", label: "Hungarian" }, + { value: "ta", label: "Tamil" }, + { value: "no", label: "Norwegian" }, + { value: "th", label: "Thai" }, + { value: "ur", label: "Urdu" }, + { value: "hr", label: "Croatian" }, + { value: "bg", label: "Bulgarian" }, + { value: "lt", label: "Lithuanian" }, + { value: "la", label: "Latin" }, + { value: "mi", label: "Maori" }, + { value: "ml", label: "Malayalam" }, + { value: "cy", label: "Welsh" }, + { value: "sk", label: "Slovak" }, + { value: "te", label: "Telugu" }, + { value: "fa", label: "Persian" }, + { value: "lv", label: "Latvian" }, + { value: "bn", label: "Bengali" }, + { value: "sr", label: "Serbian" }, + { value: "az", label: "Azerbaijani" }, + { value: "sl", label: "Slovenian" }, + { value: "kn", label: "Kannada" }, + { value: "et", label: "Estonian" }, + { value: "mk", label: "Macedonian" }, + { value: "br", label: "Breton" }, +]; \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index dfec7b3..cde2622 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -34,6 +34,7 @@ export const SettingsSchema = z.object({ selected_language: z.string(), overlay_position: OverlayPositionSchema, debug_mode: z.boolean(), + custom_words: z.array(z.string()).optional().default([]), }); export const BindingResponseSchema = z.object({