stored settings for language, and also overlay is now transcribed (#465)

This commit is contained in:
CJ Pais 2025-12-15 20:01:23 -08:00 committed by GitHub
parent 57629f1374
commit ffd38f4d89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 90 additions and 27 deletions

View file

@ -261,6 +261,7 @@ pub fn run() {
shortcut::resume_binding, shortcut::resume_binding,
shortcut::change_mute_while_recording_setting, shortcut::change_mute_while_recording_setting,
shortcut::change_append_trailing_space_setting, shortcut::change_append_trailing_space_setting,
shortcut::change_app_language_setting,
shortcut::change_update_checks_setting, shortcut::change_update_checks_setting,
trigger_update_check, trigger_update_check,
commands::cancel_operation, commands::cancel_operation,

View file

@ -291,6 +291,8 @@ pub struct AppSettings {
pub mute_while_recording: bool, pub mute_while_recording: bool,
#[serde(default)] #[serde(default)]
pub append_trailing_space: bool, pub append_trailing_space: bool,
#[serde(default = "default_app_language")]
pub app_language: String,
} }
fn default_model() -> String { fn default_model() -> String {
@ -360,6 +362,10 @@ fn default_post_process_enabled() -> bool {
false false
} }
fn default_app_language() -> String {
"en".to_string()
}
fn default_post_process_provider_id() -> String { fn default_post_process_provider_id() -> String {
"openai".to_string() "openai".to_string()
} }
@ -554,6 +560,7 @@ pub fn get_default_settings() -> AppSettings {
post_process_selected_prompt_id: None, post_process_selected_prompt_id: None,
mute_while_recording: false, mute_while_recording: false,
append_trailing_space: false, append_trailing_space: false,
app_language: default_app_language(),
} }
} }

View file

@ -717,6 +717,16 @@ pub fn change_append_trailing_space_setting(app: AppHandle, enabled: bool) -> Re
Ok(()) Ok(())
} }
#[tauri::command]
#[specta::specta]
pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.app_language = language;
settings::write_settings(&app, settings);
Ok(())
}
/// Determine whether a shortcut string contains at least one non-modifier key. /// 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 /// We allow single non-modifier keys (e.g. "f5" or "space") but disallow
/// modifier-only combos (e.g. "ctrl" or "ctrl+shift"). /// modifier-only combos (e.g. "ctrl" or "ctrl+shift").

View file

@ -252,6 +252,14 @@ async changeAppendTrailingSpaceSetting(enabled: boolean) : Promise<Result<null,
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async changeAppLanguageSetting(language: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_app_language_setting", { language }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeUpdateChecksSetting(enabled: boolean) : Promise<Result<null, string>> { async changeUpdateChecksSetting(enabled: boolean) : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("change_update_checks_setting", { enabled }) }; return { status: "ok", data: await TAURI_INVOKE("change_update_checks_setting", { enabled }) };
@ -613,7 +621,7 @@ async isLaptop() : Promise<Result<boolean, string>> {
/** user-defined types **/ /** user-defined types **/
export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: string; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean } export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: string; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string }
export type AudioDevice = { index: string; name: string; is_default: boolean } export type AudioDevice = { index: string; name: string; is_default: boolean }
export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null } export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null }
export type ClipboardHandling = "dont_modify" | "copy_to_clipboard" export type ClipboardHandling = "dont_modify" | "copy_to_clipboard"

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Dropdown } from "../ui/Dropdown"; import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer"; import { SettingContainer } from "../ui/SettingContainer";
import { SUPPORTED_LANGUAGES, type SupportedLanguageCode } from "../../i18n"; import { SUPPORTED_LANGUAGES, type SupportedLanguageCode } from "../../i18n";
import { useSettings } from "@/hooks/useSettings";
interface AppLanguageSelectorProps { interface AppLanguageSelectorProps {
descriptionMode?: "inline" | "tooltip"; descriptionMode?: "inline" | "tooltip";
@ -12,8 +13,10 @@ interface AppLanguageSelectorProps {
export const AppLanguageSelector: React.FC<AppLanguageSelectorProps> = export const AppLanguageSelector: React.FC<AppLanguageSelectorProps> =
React.memo(({ descriptionMode = "tooltip", grouped = false }) => { React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { settings, updateSetting } = useSettings();
const currentLanguage = i18n.language as SupportedLanguageCode; const currentLanguage = (settings?.app_language ||
i18n.language) as SupportedLanguageCode;
const languageOptions = SUPPORTED_LANGUAGES.map((lang) => ({ const languageOptions = SUPPORTED_LANGUAGES.map((lang) => ({
value: lang.code, value: lang.code,
@ -22,8 +25,7 @@ export const AppLanguageSelector: React.FC<AppLanguageSelectorProps> =
const handleLanguageChange = (langCode: string) => { const handleLanguageChange = (langCode: string) => {
i18n.changeLanguage(langCode); i18n.changeLanguage(langCode);
// Persist to localStorage for next session updateSetting("app_language", langCode);
localStorage.setItem("handy-app-language", langCode);
}; };
return ( return (

View file

@ -2,6 +2,7 @@ import i18n from "i18next";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import { locale } from "@tauri-apps/plugin-os"; import { locale } from "@tauri-apps/plugin-os";
import { LANGUAGE_METADATA } from "./languages"; import { LANGUAGE_METADATA } from "./languages";
import { commands } from "@/bindings";
// Auto-discover translation files using Vite's glob import // Auto-discover translation files using Vite's glob import
const localeModules = import.meta.glob<{ default: Record<string, unknown> }>( const localeModules = import.meta.glob<{ default: Record<string, unknown> }>(
@ -55,17 +56,11 @@ const getSupportedLanguage = (
return supported ? supported.code : null; return supported ? supported.code : null;
}; };
// Get saved language from localStorage // Initialize i18n with English as default
const getSavedLanguage = (): SupportedLanguageCode | null => { // Language will be synced from settings after init
const savedLang = localStorage.getItem("handy-app-language");
return getSupportedLanguage(savedLang);
};
// Initialize i18n with saved language or English as default
// System locale detection happens async after init
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
resources, resources,
lng: getSavedLanguage() || "en", lng: "en",
fallbackLng: "en", fallbackLng: "en",
interpolation: { interpolation: {
escapeValue: false, // React already escapes values escapeValue: false, // React already escapes values
@ -75,23 +70,29 @@ i18n.use(initReactI18next).init({
}, },
}); });
// After init, check system locale if no saved preference // Sync language from app settings
const initSystemLocale = async () => { export const syncLanguageFromSettings = async () => {
// Skip if user has explicitly set a language
if (getSavedLanguage()) return;
try { try {
const systemLocale = await locale(); const result = await commands.getAppSettings();
const supported = getSupportedLanguage(systemLocale); if (result.status === "ok" && result.data.app_language) {
if (supported && supported !== i18n.language) { const supported = getSupportedLanguage(result.data.app_language);
await i18n.changeLanguage(supported); if (supported && supported !== i18n.language) {
await i18n.changeLanguage(supported);
}
} else {
// Fall back to system locale detection if no saved preference
const systemLocale = await locale();
const supported = getSupportedLanguage(systemLocale);
if (supported && supported !== i18n.language) {
await i18n.changeLanguage(supported);
}
} }
} catch (e) { } catch (e) {
console.warn("Failed to get system locale:", e); console.warn("Failed to sync language from settings:", e);
} }
}; };
// Run async locale detection // Run language sync on init
initSystemLocale(); syncLanguageFromSettings();
export default i18n; export default i18n;

View file

@ -406,5 +406,8 @@
"appLanguage": { "appLanguage": {
"title": "Anwendungssprache", "title": "Anwendungssprache",
"description": "Sprache der Handy-Oberfläche ändern" "description": "Sprache der Handy-Oberfläche ändern"
},
"overlay": {
"transcribing": "Transkribiere..."
} }
} }

View file

@ -406,5 +406,8 @@
"appLanguage": { "appLanguage": {
"title": "Application Language", "title": "Application Language",
"description": "Change the language of the Handy interface" "description": "Change the language of the Handy interface"
},
"overlay": {
"transcribing": "Transcribing..."
} }
} }

View file

@ -399,5 +399,8 @@
"appLanguage": { "appLanguage": {
"title": "Idioma de la aplicación", "title": "Idioma de la aplicación",
"description": "Cambia el idioma de la interfaz de Handy" "description": "Cambia el idioma de la interfaz de Handy"
},
"overlay": {
"transcribing": "Transcribiendo..."
} }
} }

View file

@ -400,5 +400,8 @@
"appLanguage": { "appLanguage": {
"title": "Langue de l'application", "title": "Langue de l'application",
"description": "Changer la langue de l'interface de Handy" "description": "Changer la langue de l'interface de Handy"
},
"overlay": {
"transcribing": "Transcription..."
} }
} }

View file

@ -406,5 +406,8 @@
"appLanguage": { "appLanguage": {
"title": "Lingua Applicazione", "title": "Lingua Applicazione",
"description": "Cambia la lingua dell'interfaccia di Handy" "description": "Cambia la lingua dell'interfaccia di Handy"
},
"overlay": {
"transcribing": "Trascrizione..."
} }
} }

View file

@ -406,5 +406,8 @@
"appLanguage": { "appLanguage": {
"title": "アプリケーション言語", "title": "アプリケーション言語",
"description": "Handyインターフェースの言語を変更" "description": "Handyインターフェースの言語を変更"
},
"overlay": {
"transcribing": "文字起こし中..."
} }
} }

View file

@ -406,5 +406,8 @@
"appLanguage": { "appLanguage": {
"title": "Język aplikacji", "title": "Język aplikacji",
"description": "Zmień język interfejsu Handy" "description": "Zmień język interfejsu Handy"
},
"overlay": {
"transcribing": "Transkrypcja..."
} }
} }

View file

@ -400,5 +400,8 @@
"appLanguage": { "appLanguage": {
"title": "Ngôn ngữ ứng dụng", "title": "Ngôn ngữ ứng dụng",
"description": "Thay đổi ngôn ngữ giao diện của Handy" "description": "Thay đổi ngôn ngữ giao diện của Handy"
},
"overlay": {
"transcribing": "Đang chuyển đổi..."
} }
} }

View file

@ -406,5 +406,8 @@
"appLanguage": { "appLanguage": {
"title": "应用语言", "title": "应用语言",
"description": "更改 Handy 界面的语言" "description": "更改 Handy 界面的语言"
},
"overlay": {
"transcribing": "正在转录..."
} }
} }

View file

@ -1,5 +1,6 @@
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
MicrophoneIcon, MicrophoneIcon,
TranscriptionIcon, TranscriptionIcon,
@ -7,10 +8,12 @@ import {
} from "../components/icons"; } from "../components/icons";
import "./RecordingOverlay.css"; import "./RecordingOverlay.css";
import { commands } from "@/bindings"; import { commands } from "@/bindings";
import { syncLanguageFromSettings } from "@/i18n";
type OverlayState = "recording" | "transcribing"; type OverlayState = "recording" | "transcribing";
const RecordingOverlay: React.FC = () => { const RecordingOverlay: React.FC = () => {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [state, setState] = useState<OverlayState>("recording"); const [state, setState] = useState<OverlayState>("recording");
const [levels, setLevels] = useState<number[]>(Array(16).fill(0)); const [levels, setLevels] = useState<number[]>(Array(16).fill(0));
@ -19,7 +22,9 @@ const RecordingOverlay: React.FC = () => {
useEffect(() => { useEffect(() => {
const setupEventListeners = async () => { const setupEventListeners = async () => {
// Listen for show-overlay event from Rust // Listen for show-overlay event from Rust
const unlistenShow = await listen("show-overlay", (event) => { const unlistenShow = await listen("show-overlay", async (event) => {
// Sync language from settings each time overlay is shown
await syncLanguageFromSettings();
const overlayState = event.payload as OverlayState; const overlayState = event.payload as OverlayState;
setState(overlayState); setState(overlayState);
setIsVisible(true); setIsVisible(true);
@ -84,7 +89,7 @@ const RecordingOverlay: React.FC = () => {
</div> </div>
)} )}
{state === "transcribing" && ( {state === "transcribing" && (
<div className="transcribing-text">Transcribing...</div> <div className="transcribing-text">{t("overlay.transcribing")}</div>
)} )}
</div> </div>

View file

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import RecordingOverlay from "./RecordingOverlay"; import RecordingOverlay from "./RecordingOverlay";
import "@/i18n";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>

View file

@ -124,6 +124,7 @@ const settingUpdaters: {
append_trailing_space: (value) => append_trailing_space: (value) =>
commands.changeAppendTrailingSpaceSetting(value as boolean), commands.changeAppendTrailingSpaceSetting(value as boolean),
log_level: (value) => commands.setLogLevel(value as any), log_level: (value) => commands.setLogLevel(value as any),
app_language: (value) => commands.changeAppLanguageSetting(value as string),
}; };
export const useSettingsStore = create<SettingsStore>()( export const useSettingsStore = create<SettingsStore>()(