Merge branch 'main' into cancel-actions-in-menu
This commit is contained in:
commit
a7c5b199fb
14 changed files with 280 additions and 29 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "handy-app",
|
||||
"private": true,
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -2003,7 +2003,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "handy"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cpal",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "handy"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
description = "Handy"
|
||||
authors = ["cjpais"]
|
||||
edition = "2021"
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@
|
|||
"default_binding": "Platform-specific: ctrl+space (Windows/Linux), alt+space (macOS)"
|
||||
}
|
||||
},
|
||||
"push_to_talk": true
|
||||
"push_to_talk": true,
|
||||
"selected_language": "auto"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,28 @@ struct ShortcutToggleStates {
|
|||
|
||||
type ManagedToggleState = Mutex<ShortcutToggleStates>;
|
||||
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
if let Some(main_window) = app.get_webview_window("main") {
|
||||
// First, ensure the window is visible
|
||||
if let Err(e) = main_window.show() {
|
||||
eprintln!("Failed to show window: {}", e);
|
||||
}
|
||||
// Then, bring it to the front and give it focus
|
||||
if let Err(e) = main_window.set_focus() {
|
||||
eprintln!("Failed to focus window: {}", e);
|
||||
}
|
||||
// Optional: On macOS, ensure the app becomes active if it was an accessory
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Err(e) = app.set_activation_policy(tauri::ActivationPolicy::Regular) {
|
||||
eprintln!("Failed to set activation policy to Regular: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Main window not found");
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn trigger_update_check(app: AppHandle) -> Result<(), String> {
|
||||
app.emit("check-for-updates", ())
|
||||
|
|
@ -58,31 +80,13 @@ pub fn run() {
|
|||
tauri::path::BaseDirectory::Resource,
|
||||
)?)?)
|
||||
.show_menu_on_left_click(true)
|
||||
.icon_as_template(true)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"settings" => {
|
||||
if let Some(settings_window) = app.get_webview_window("main") {
|
||||
// First, ensure the window is visible
|
||||
if let Err(e) = settings_window.show() {
|
||||
eprintln!("Failed to show window: {}", e);
|
||||
}
|
||||
// Then, bring it to the front and give it focus
|
||||
if let Err(e) = settings_window.set_focus() {
|
||||
eprintln!("Failed to focus window: {}", e);
|
||||
}
|
||||
// Optional: On macOS, ensure the app becomes active if it was an accessory
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Err(e) =
|
||||
app.set_activation_policy(tauri::ActivationPolicy::Regular)
|
||||
{
|
||||
eprintln!("Failed to set activation policy to Regular: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Main window not found");
|
||||
}
|
||||
show_main_window(app);
|
||||
}
|
||||
"check_updates" => {
|
||||
show_main_window(app);
|
||||
let _ = app.emit("check-for-updates", ());
|
||||
}
|
||||
"cancel" => {
|
||||
|
|
@ -148,6 +152,7 @@ pub fn run() {
|
|||
shortcut::change_ptt_setting,
|
||||
shortcut::change_audio_feedback_setting,
|
||||
shortcut::change_translate_to_english_setting,
|
||||
shortcut::change_selected_language_setting,
|
||||
trigger_update_check,
|
||||
commands::cancel_operation,
|
||||
commands::models::get_available_models,
|
||||
|
|
|
|||
|
|
@ -178,7 +178,8 @@ impl TranscriptionManager {
|
|||
|
||||
// Initialize parameters
|
||||
let mut params = FullParams::new(SamplingStrategy::default());
|
||||
params.set_language(Some("auto"));
|
||||
let language = Some(settings.selected_language.as_str());
|
||||
params.set_language(language);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ pub struct AppSettings {
|
|||
pub selected_output_device: Option<String>,
|
||||
#[serde(default = "default_translate_to_english")]
|
||||
pub translate_to_english: bool,
|
||||
#[serde(default = "default_selected_language")]
|
||||
pub selected_language: String,
|
||||
}
|
||||
|
||||
fn default_model() -> String {
|
||||
|
|
@ -47,6 +49,11 @@ fn default_translate_to_english() -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
fn default_selected_language() -> String {
|
||||
// Default to auto-detection for backward compatibility
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
pub const SETTINGS_STORE_PATH: &str = "settings_store.json";
|
||||
|
||||
pub fn get_default_settings() -> AppSettings {
|
||||
|
|
@ -91,6 +98,7 @@ pub fn get_default_settings() -> AppSettings {
|
|||
selected_microphone: None,
|
||||
selected_output_device: None,
|
||||
translate_to_english: false,
|
||||
selected_language: "auto".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,14 @@ pub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Res
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_selected_language_setting(app: AppHandle, language: String) -> Result<(), String> {
|
||||
let mut settings = settings::get_settings(&app);
|
||||
settings.selected_language = language;
|
||||
settings::write_settings(&app, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
|
||||
// Parse shortcut and return error if it fails
|
||||
let shortcut = match binding.current_binding.parse::<Shortcut>() {
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState) {
|
|||
|
||||
let tray = app.state::<TrayIcon>();
|
||||
let _ = tray.set_menu(Some(menu));
|
||||
let _ = tray.set_icon_as_template(true);
|
||||
}
|
||||
|
||||
/// Plays an audio resource from the resources directory.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Handy",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"identifier": "com.pais.handy",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
|
@ -14,9 +14,9 @@
|
|||
{
|
||||
"title": "Handy",
|
||||
"width": 540,
|
||||
"height": 630,
|
||||
"height": 680,
|
||||
"minWidth": 540,
|
||||
"minHeight": 630,
|
||||
"minHeight": 680,
|
||||
"resizable": true,
|
||||
"maximizable": false
|
||||
}
|
||||
|
|
|
|||
218
src/components/settings/LanguageSelector.tsx
Normal file
218
src/components/settings/LanguageSelector.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { SettingContainer } from "../ui/SettingContainer";
|
||||
import ResetIcon from "../icons/ResetIcon";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
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<LanguageSelectorProps> = ({
|
||||
descriptionMode = "tooltip",
|
||||
grouped = false,
|
||||
}) => {
|
||||
const { getSetting, updateSetting, resetSetting, isUpdating } = useSettings();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedLanguage = getSetting("selected_language") || "auto";
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const filteredLanguages = LANGUAGES.filter((language) =>
|
||||
language.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const selectedLanguageName =
|
||||
LANGUAGES.find((lang) => lang.code === selectedLanguage)?.name || "Auto";
|
||||
|
||||
const handleLanguageSelect = async (languageCode: string) => {
|
||||
await updateSetting("selected_language", languageCode);
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
await resetSetting("selected_language");
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isUpdating("selected_language")) return;
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(event.target.value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && filteredLanguages.length > 0) {
|
||||
// Select first filtered language on Enter
|
||||
handleLanguageSelect(filteredLanguages[0].code);
|
||||
} else if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingContainer
|
||||
title="Language"
|
||||
description="Select the language for speech recognition. Auto will automatically determine the language, while selecting a specific language can improve accuracy for that language."
|
||||
descriptionMode={descriptionMode}
|
||||
grouped={grouped}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded min-w-[200px] text-left flex items-center justify-between transition-all duration-150 ${
|
||||
isUpdating("selected_language")
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
|
||||
}`}
|
||||
onClick={handleToggle}
|
||||
disabled={isUpdating("selected_language")}
|
||||
>
|
||||
<span className="truncate">{selectedLanguageName}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 ml-2 transition-transform duration-200 ${
|
||||
isOpen ? "transform rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && !isUpdating("selected_language") && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded shadow-lg z-50 max-h-60 overflow-hidden">
|
||||
{/* Search input */}
|
||||
<div className="p-2 border-b border-mid-gray/80">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search languages..."
|
||||
className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded focus:outline-none focus:ring-1 focus:ring-logo-primary focus:border-logo-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredLanguages.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-mid-gray text-center">
|
||||
No languages found
|
||||
</div>
|
||||
) : (
|
||||
filteredLanguages.map((language) => (
|
||||
<button
|
||||
key={language.code}
|
||||
type="button"
|
||||
className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${
|
||||
selectedLanguage === language.code
|
||||
? "bg-logo-primary/20 text-logo-primary font-semibold"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleLanguageSelect(language.code)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate">{language.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
|
||||
onClick={handleReset}
|
||||
disabled={isUpdating("selected_language")}
|
||||
>
|
||||
<ResetIcon className="" />
|
||||
</button>
|
||||
</div>
|
||||
{isUpdating("selected_language") && (
|
||||
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
|
||||
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</SettingContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@ import { AudioFeedback } from "./AudioFeedback";
|
|||
import { OutputDeviceSelector } from "./OutputDeviceSelector";
|
||||
import { HandyShortcut } from "./HandyShortcut";
|
||||
import { TranslateToEnglish } from "./TranslateToEnglish";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
|
|
@ -14,6 +15,7 @@ export const Settings: React.FC = () => {
|
|||
<SettingsGroup>
|
||||
<HandyShortcut descriptionMode="tooltip" grouped={true} />
|
||||
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
|
||||
<LanguageSelector descriptionMode="tooltip" grouped={true} />
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Advanced">
|
||||
|
|
|
|||
|
|
@ -194,6 +194,11 @@ export const useSettings = (): UseSettingsReturn => {
|
|||
enabled: value,
|
||||
});
|
||||
break;
|
||||
case "selected_language":
|
||||
await invoke("change_selected_language_setting", {
|
||||
language: value,
|
||||
});
|
||||
break;
|
||||
case "bindings":
|
||||
// Handle bindings separately - they use their own invoke methods
|
||||
break;
|
||||
|
|
@ -237,6 +242,7 @@ export const useSettings = (): UseSettingsReturn => {
|
|||
selected_microphone: "Default",
|
||||
selected_output_device: "Default",
|
||||
translate_to_english: false,
|
||||
selected_language: "auto",
|
||||
};
|
||||
|
||||
const defaultValue = defaults[key];
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export const SettingsSchema = z.object({
|
|||
selected_microphone: z.string().nullable().optional(),
|
||||
selected_output_device: z.string().nullable().optional(),
|
||||
translate_to_english: z.boolean(),
|
||||
selected_language: z.string(),
|
||||
});
|
||||
|
||||
export const BindingResponseSchema = z.object({
|
||||
|
|
|
|||
Loading…
Reference in a new issue