From 92dad79034ef439224489890a839c1451e00940e Mon Sep 17 00:00:00 2001 From: nitintf Date: Mon, 23 Feb 2026 08:05:57 +0530 Subject: [PATCH] feat: new voice iinput window ui and some minor optimizations --- src-tauri/src/features/audio/commands.rs | 124 ++++--- .../src/features/cache/settings_cache.rs | 255 +++++++++++---- src-tauri/src/features/security/mod.rs | 26 +- src-tauri/src/features/shortcuts/manager.rs | 124 +++---- src-tauri/src/features/window/mod.rs | 4 +- src-tauri/src/features/window/pill_window.rs | 303 ++++++++++++------ src-tauri/src/features/window/toast_window.rs | 27 +- src-tauri/src/lib.rs | 27 ++ src-tauri/src/menu.rs | 61 ++-- src-tauri/src/types/settings.rs | 13 + .../components/panels/general-panel.tsx | 146 +-------- .../components/panels/settings-panel.tsx | 40 +++ .../components/panels/shortcuts-panel.tsx | 19 ++ .../components/panels/transcription-panel.tsx | 117 ++++++- .../settings/components/settings-dialog.tsx | 6 +- .../settings/components/theme-selector.tsx | 61 ---- .../visual-display-mode-selector.tsx | 98 ++++++ .../components/visual-theme-selector.tsx | 115 +++++++ src/features/settings/schema.ts | 2 + src/features/settings/store.ts | 30 +- src/features/settings/types.ts | 3 +- .../types/generated/VoiceInputDisplayMode.ts | 6 + .../types/generated/VoiceInputSettings.ts | 3 +- .../settings/types/generated/index.ts | 1 + .../components/voice-input-container.tsx | 20 +- .../voice-input/components/voice-input.tsx | 79 ++++- 26 files changed, 1162 insertions(+), 548 deletions(-) delete mode 100644 src/features/settings/components/theme-selector.tsx create mode 100644 src/features/settings/components/visual-display-mode-selector.tsx create mode 100644 src/features/settings/components/visual-theme-selector.tsx create mode 100644 src/features/settings/types/generated/VoiceInputDisplayMode.ts diff --git a/src-tauri/src/features/audio/commands.rs b/src-tauri/src/features/audio/commands.rs index db8b33a..4481fa8 100644 --- a/src-tauri/src/features/audio/commands.rs +++ b/src-tauri/src/features/audio/commands.rs @@ -1,6 +1,7 @@ use super::player::{play_error_sound, play_recording_start_sound, play_recording_stop_sound}; use super::recorder::AudioRecorder; use super::state::{RecordingState, RecordingStateManager}; +use crate::types::settings::VoiceInputDisplayMode; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -28,6 +29,18 @@ fn try_lock_with_timeout( .ok_or_else(|| "Lock acquisition timed out - operation in progress".to_string()) } +/// Hide the recording window +#[cfg(target_os = "macos")] +fn hide_recording_window(app: &AppHandle) { + use tauri::Manager; + + if let Some(window) = app.get_webview_window("voice-input") { + let _ = window.hide(); + } + + crate::features::window::set_pill_monitor_active(false); +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "../../src/features/voice-input/types/generated/")] #[serde(rename_all = "camelCase")] @@ -38,6 +51,13 @@ pub struct RecordingResponse { pub file_path: Option, } +/// Payload for voice-input-mode event sent to React +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceInputModePayload { + pub display_mode: VoiceInputDisplayMode, +} + #[command] pub async fn start_recording( app: AppHandle, @@ -137,12 +157,13 @@ pub async fn start_recording( } // Get settings from cache (faster) with fallback to direct store access - let (device_id, play_sound) = if let Some(cache) = + let (device_id, play_sound, display_mode) = if let Some(cache) = app.try_state::>() { ( cache.get_microphone_device_id(), cache.get_play_sound_on_recording().unwrap_or(true), + cache.get_display_mode(), ) } else { // Fallback to direct store access if cache not available @@ -167,7 +188,20 @@ pub async fn start_recording( .and_then(|p| p.as_bool()) .unwrap_or(true); - (device_id, play_sound) + let display_mode = settings + .as_ref() + .and_then(|s| s.as_object()) + .and_then(|obj| obj.get("voiceInput").and_then(|v| v.as_object())) + .and_then(|obj| obj.get("displayMode")) + .and_then(|d| d.as_str()) + .and_then(|s| match s { + "standard" => Some(VoiceInputDisplayMode::Standard), + "minimal" => Some(VoiceInputDisplayMode::Minimal), + _ => None, + }) + .unwrap_or(VoiceInputDisplayMode::Standard); + + (device_id, play_sound, display_mode) }; // Transition to Starting state @@ -197,20 +231,39 @@ pub async fn start_recording( // Set app handle for emitting audio levels recorder_guard.set_app_handle(app.clone()); - // Show window BEFORE starting recording so it appears immediately - // This ensures the window is visible even if the recording start is delayed (e.g., external microphone) - if let Some(window) = app.get_webview_window("voice-input") { - // Position window on the screen where the mouse cursor is - #[cfg(target_os = "macos")] - { - if let Err(e) = crate::features::window::position_pill_window_on_current_screen(&app) { - log::warn!("Failed to position pill window: {:?}", e); + // Show the voice input window with appropriate display mode + #[cfg(target_os = "macos")] + { + use tauri::Manager; + + if let Some(window) = app.get_webview_window("voice-input") { + // Emit display mode to React BEFORE showing window to avoid button flash + let _ = app.emit( + "voice-input-mode", + VoiceInputModePayload { + display_mode: display_mode.clone(), + }, + ); + + // Configure window size and position based on display mode + if let Err(e) = + crate::features::window::configure_pill_window_for_mode(&app, &display_mode) + { + log::warn!("Failed to configure pill window for mode: {:?}", e); + if let Err(e) = + crate::features::window::position_pill_window_on_current_screen(&app) + { + log::warn!("Failed to position pill window: {:?}", e); + } } - } - let _ = window.show(); - // Don't steal focus - keep user focused on their active application - log::info!("Voice input window shown before recording start"); + let _ = window.show(); + + log::info!( + "Voice input window shown with mode {:?} before recording start", + display_mode + ); + } } match recorder_guard.start_recording(&file_path, device_id) { @@ -282,9 +335,8 @@ pub async fn start_recording( let _ = app.emit("recording-state-changed", RecordingState::Error); - if let Some(window) = app.get_webview_window("voice-input") { - let _ = window.hide(); - } + #[cfg(target_os = "macos")] + hide_recording_window(&app); Err(e) } @@ -528,13 +580,9 @@ pub async fn stop_recording( let _ = app_clone.emit("recording-state-changed", RecordingState::Idle); } - if let Some(window) = app_clone.get_webview_window("voice-input") { - let _ = window.hide(); - } - - // Disable active pill window monitoring when not recording + // Hide all recording windows #[cfg(target_os = "macos")] - crate::features::window::set_pill_monitor_active(false); + hide_recording_window(&app_clone); }); } @@ -606,13 +654,9 @@ pub async fn stop_recording( log::warn!("ShortcutManager not available, cannot unregister Escape on error"); } - if let Some(window) = app.get_webview_window("voice-input") { - let _ = window.hide(); - } - - // Disable active pill window monitoring on error + // Hide all recording windows on error #[cfg(target_os = "macos")] - crate::features::window::set_pill_monitor_active(false); + hide_recording_window(&app); Err(e) } @@ -745,13 +789,10 @@ pub async fn cancel_recording( 1.0, ); - let app_clone = app.clone(); - tokio::spawn(async move { - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - if let Some(window) = app_clone.get_webview_window("voice-input") { - let _ = window.hide(); - } - }); + // Hide recording window immediately, not delayed + // A delayed hide can race with a new recording starting and hide the new window + #[cfg(target_os = "macos")] + hide_recording_window(&app); log::info!("Recording cancelled successfully"); @@ -797,18 +838,13 @@ pub async fn force_reset_recording( // Reset debounce timer *LAST_COMMAND_TIME.lock() = None; - // Hide voice input window if visible - if let Some(window) = app.get_webview_window("voice-input") { - let _ = window.hide(); - } + // Hide all recording windows + #[cfg(target_os = "macos")] + hide_recording_window(&app); // Emit state change let _ = app.emit("recording-state-changed", RecordingState::Idle); - // Disable pill window monitoring - #[cfg(target_os = "macos")] - crate::features::window::set_pill_monitor_active(false); - log::info!("Recording state force reset to Idle"); Ok(RecordingResponse { diff --git a/src-tauri/src/features/cache/settings_cache.rs b/src-tauri/src/features/cache/settings_cache.rs index 6f4e616..ce21ac9 100644 --- a/src-tauri/src/features/cache/settings_cache.rs +++ b/src-tauri/src/features/cache/settings_cache.rs @@ -4,49 +4,86 @@ //! eliminating redundant disk reads. The cache is initialized on app startup //! and invalidated when settings change. //! +//! CRITICAL: This cache is designed to be LOCK-FREE for reads during recording. +//! The audio thread reads settings frequently, and any lock contention can cause +//! the app to hang. We use atomic operations and copy-on-write semantics. +//! //! Uses parking_lot::RwLock instead of std::sync::RwLock because: //! - No lock poisoning on panic (more resilient) //! - Faster locking operations //! - Consistent with other locks in the codebase +use crate::types::settings::VoiceInputDisplayMode; use parking_lot::RwLock; use serde_json::Value; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use tauri::AppHandle; use tauri_plugin_store::StoreExt; -/// Thread-safe settings cache. +/// Snapshot of cached settings - immutable once created. +/// This allows lock-free reads by swapping Arc pointers atomically. +#[derive(Debug, Clone)] +struct CachedSettings { + settings: Option, + microphone_device_id: Option, + play_sound_on_recording: Option, + voice_input_shortcut: Option, + ptt_shortcut: Option, + enable_push_to_talk: Option, + paste_shortcut: Option, + global_shortcuts_enabled: Option, + display_mode: Option, +} + +impl Default for CachedSettings { + fn default() -> Self { + Self { + settings: None, + microphone_device_id: None, + play_sound_on_recording: None, + voice_input_shortcut: None, + ptt_shortcut: None, + enable_push_to_talk: None, + paste_shortcut: None, + global_shortcuts_enabled: None, + display_mode: None, + } + } +} + +/// Thread-safe settings cache with fast lock-free read path. /// -/// Uses parking_lot::RwLock for efficient concurrent read access with exclusive write access. -/// Unlike std::sync::RwLock, parking_lot doesn't poison locks on panic, making it more -/// resilient to crashes in other parts of the application. +/// The cache uses a single RwLock protecting an Arc. +/// Reads acquire a read lock briefly to clone the Arc (cheap), then release. +/// Writes create a new CachedSettings, wrap in Arc, and swap atomically. /// -/// The cache stores both the full settings object and extracted values for -/// frequently accessed settings to minimize JSON traversal overhead. +/// This design ensures: +/// - Reads never block for more than an Arc clone (nanoseconds) +/// - Writes don't block active readers +/// - No deadlocks possible pub struct SettingsCache { - settings: RwLock>, - microphone_device_id: RwLock>, - play_sound_on_recording: RwLock>, - voice_input_shortcut: RwLock>, - ptt_shortcut: RwLock>, - enable_push_to_talk: RwLock>, - paste_shortcut: RwLock>, - global_shortcuts_enabled: RwLock>, + /// The current cached settings snapshot + cached: RwLock>, + /// Flag to track if cache has been initialized + initialized: AtomicBool, } impl SettingsCache { pub fn new() -> Self { Self { - settings: RwLock::new(None), - microphone_device_id: RwLock::new(None), - play_sound_on_recording: RwLock::new(None), - voice_input_shortcut: RwLock::new(None), - ptt_shortcut: RwLock::new(None), - enable_push_to_talk: RwLock::new(None), - paste_shortcut: RwLock::new(None), - global_shortcuts_enabled: RwLock::new(None), + cached: RwLock::new(Arc::new(CachedSettings::default())), + initialized: AtomicBool::new(false), } } + /// Get a snapshot of the current cached settings. + /// This is extremely fast - just clones an Arc pointer. + #[inline] + fn get_snapshot(&self) -> Arc { + self.cached.read().clone() + } + /// Initialize the cache from the settings store. /// /// Call this during app startup after the store is available. @@ -61,28 +98,35 @@ impl SettingsCache { self.update_from_value(&settings_value); } + self.initialized.store(true, Ordering::Release); log::debug!("Settings cache initialized"); Ok(()) } - /// Invalidate the cache and reload from disk. + /// Invalidate the cache and reload from the in-memory store. + /// + /// IMPORTANT: This does NOT call store.reload() because: + /// 1. store.reload() is synchronous and can block for disk I/O + /// 2. The frontend writes to the store first, then calls invalidate + /// 3. The in-memory store already has the latest data /// - /// Call this when settings are modified to ensure cache consistency. + /// This design prevents deadlocks when settings dialog opens during recording. pub fn invalidate(&self, app: &AppHandle) -> Result<(), String> { let store = app .store("settings") .map_err(|e| format!("Failed to get settings store: {}", e))?; - // Force reload from disk to get latest changes - store - .reload() - .map_err(|e| format!("Failed to reload store: {}", e))?; + // DO NOT call store.reload() here! + // The store already has the latest data in memory from the frontend write. + // Calling reload() would: + // 1. Block for disk I/O + // 2. Potentially cause deadlock if called while recording thread is reading let settings = store.get("settings"); if let Some(settings_value) = settings { self.update_from_value(&settings_value); - log::debug!("Settings cache invalidated and reloaded"); + log::debug!("Settings cache invalidated from in-memory store"); } else { // Clear cache if no settings found self.clear(); @@ -92,115 +136,146 @@ impl SettingsCache { Ok(()) } - /// Update cache from a settings Value. + /// Update cache from a settings Value using atomic swap. /// - /// parking_lot::RwLock never panics on lock acquisition, so we can - /// safely use it without Result handling. + /// This creates a new CachedSettings snapshot, populates it completely, + /// then atomically swaps it in. This ensures: + /// - Readers always see a consistent snapshot + /// - No partial updates are visible + /// - Write lock is held for minimum time (just the swap) fn update_from_value(&self, settings: &Value) { - // Store full settings - *self.settings.write() = Some(settings.clone()); + // Build the new snapshot WITHOUT holding any locks + let mut new_cache = CachedSettings { + settings: Some(settings.clone()), + ..Default::default() + }; // Extract and cache frequently accessed values if let Some(obj) = settings.as_object() { // voiceInput settings if let Some(voice_input) = obj.get("voiceInput").and_then(|v| v.as_object()) { - *self.microphone_device_id.write() = voice_input + new_cache.microphone_device_id = voice_input .get("microphoneDeviceId") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - *self.voice_input_shortcut.write() = voice_input + new_cache.voice_input_shortcut = voice_input .get("shortcut") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - *self.ptt_shortcut.write() = voice_input + new_cache.ptt_shortcut = voice_input .get("pushToTalkShortcut") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - *self.enable_push_to_talk.write() = voice_input + new_cache.enable_push_to_talk = voice_input .get("enablePushToTalk") .and_then(|v| v.as_bool()); + + new_cache.display_mode = voice_input + .get("displayMode") + .and_then(|v| v.as_str()) + .and_then(|s| match s { + "standard" => Some(VoiceInputDisplayMode::Standard), + "minimal" => Some(VoiceInputDisplayMode::Minimal), + _ => None, + }); } // system settings if let Some(system) = obj.get("system").and_then(|v| v.as_object()) { - *self.play_sound_on_recording.write() = + new_cache.play_sound_on_recording = system.get("playSoundOnRecording").and_then(|v| v.as_bool()); } // shortcuts settings if let Some(shortcuts) = obj.get("shortcuts").and_then(|v| v.as_object()) { - *self.paste_shortcut.write() = shortcuts + new_cache.paste_shortcut = shortcuts .get("pasteLastTranscript") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - *self.global_shortcuts_enabled.write() = shortcuts + new_cache.global_shortcuts_enabled = shortcuts .get("globalShortcutsEnabled") .and_then(|v| v.as_bool()); } } + + // Atomic swap - write lock held only for pointer swap + *self.cached.write() = Arc::new(new_cache); + self.initialized.store(true, Ordering::Release); } - /// Clear all cached values. + /// Clear all cached values using atomic swap. fn clear(&self) { - *self.settings.write() = None; - *self.microphone_device_id.write() = None; - *self.play_sound_on_recording.write() = None; - *self.voice_input_shortcut.write() = None; - *self.ptt_shortcut.write() = None; - *self.enable_push_to_talk.write() = None; - *self.paste_shortcut.write() = None; - *self.global_shortcuts_enabled.write() = None; + *self.cached.write() = Arc::new(CachedSettings::default()); + self.initialized.store(false, Ordering::Release); } /// Get the full cached settings value. pub fn get_settings(&self) -> Option { - self.settings.read().clone() + self.get_snapshot().settings.clone() } /// Get cached microphone device ID. /// Returns None if auto-detect is selected or cache is not initialized. + #[inline] pub fn get_microphone_device_id(&self) -> Option { - self.microphone_device_id.read().clone() + self.get_snapshot().microphone_device_id.clone() } /// Get cached play sound on recording setting. /// Returns None if cache is not initialized. + #[inline] pub fn get_play_sound_on_recording(&self) -> Option { - *self.play_sound_on_recording.read() + self.get_snapshot().play_sound_on_recording } /// Get cached voice input shortcut. + #[inline] pub fn get_voice_input_shortcut(&self) -> Option { - self.voice_input_shortcut.read().clone() + self.get_snapshot().voice_input_shortcut.clone() } /// Get cached push-to-talk shortcut. + #[inline] pub fn get_ptt_shortcut(&self) -> Option { - self.ptt_shortcut.read().clone() + self.get_snapshot().ptt_shortcut.clone() } /// Get cached enable push-to-talk setting. + #[inline] pub fn get_enable_push_to_talk(&self) -> Option { - *self.enable_push_to_talk.read() + self.get_snapshot().enable_push_to_talk } /// Get cached paste last transcript shortcut. + #[inline] pub fn get_paste_shortcut(&self) -> Option { - self.paste_shortcut.read().clone() + self.get_snapshot().paste_shortcut.clone() } /// Get cached global shortcuts enabled setting. + #[inline] pub fn get_global_shortcuts_enabled(&self) -> Option { - *self.global_shortcuts_enabled.read() + self.get_snapshot().global_shortcuts_enabled + } + + /// Get cached voice input display mode. + /// Returns Standard if cache is not initialized or no mode is set. + #[inline] + pub fn get_display_mode(&self) -> VoiceInputDisplayMode { + self.get_snapshot() + .display_mode + .clone() + .unwrap_or(VoiceInputDisplayMode::Standard) } /// Check if the cache has been initialized. + #[inline] pub fn is_initialized(&self) -> bool { - self.settings.read().is_some() + self.initialized.load(Ordering::Acquire) } } @@ -309,7 +384,6 @@ mod tests { #[test] fn test_thread_safety() { - use std::sync::Arc; use std::thread; let cache = Arc::new(SettingsCache::new()); @@ -325,7 +399,7 @@ mod tests { cache.update_from_value(&settings); - // Spawn multiple reader threads + // Spawn multiple reader threads - should never block let handles: Vec<_> = (0..10) .map(|_| { let cache_clone = Arc::clone(&cache); @@ -346,4 +420,63 @@ mod tests { // Cache should still be valid assert!(cache.is_initialized()); } + + #[test] + fn test_concurrent_read_write() { + use std::thread; + use std::time::Duration; + + let cache = Arc::new(SettingsCache::new()); + + // Initial settings + let settings1 = json!({ + "voiceInput": { + "shortcut": "Alt+Space" + } + }); + cache.update_from_value(&settings1); + + // Spawn reader threads + let reader_handles: Vec<_> = (0..5) + .map(|_| { + let cache_clone = Arc::clone(&cache); + thread::spawn(move || { + for _ in 0..100 { + // These should never block even during writes + let _ = cache_clone.get_voice_input_shortcut(); + let _ = cache_clone.get_display_mode(); + thread::sleep(Duration::from_micros(10)); + } + }) + }) + .collect(); + + // Spawn writer threads + let writer_handles: Vec<_> = (0..2) + .map(|i| { + let cache_clone = Arc::clone(&cache); + thread::spawn(move || { + for j in 0..10 { + let settings = json!({ + "voiceInput": { + "shortcut": format!("Alt+{}-{}", i, j) + } + }); + cache_clone.update_from_value(&settings); + thread::sleep(Duration::from_micros(50)); + } + }) + }) + .collect(); + + // All threads should complete without deadlock + for handle in reader_handles { + handle.join().unwrap(); + } + for handle in writer_handles { + handle.join().unwrap(); + } + + assert!(cache.is_initialized()); + } } diff --git a/src-tauri/src/features/security/mod.rs b/src-tauri/src/features/security/mod.rs index ced343b..d7dfae9 100644 --- a/src-tauri/src/features/security/mod.rs +++ b/src-tauri/src/features/security/mod.rs @@ -2,25 +2,33 @@ //! //! Provides secure storage of API keys using the macOS system keychain. //! Includes in-memory caching to minimize keychain access and password prompts. +//! +//! Uses parking_lot::RwLock instead of std::sync::RwLock because: +//! - No lock poisoning on panic (more resilient) +//! - Faster locking operations +//! - Consistent with other locks in the codebase pub mod keychain; pub mod legacy; pub mod migration; +use parking_lot::RwLock; use serde_json::Value; use std::collections::HashMap; -use std::sync::RwLock; use tauri::{command, AppHandle}; use tauri_plugin_store::StoreExt; /// In-memory cache for API keys to reduce keychain access /// This minimizes password prompts during a session -/// Key: model_id, Value: API key (empty string means "checked but not found") +/// Key: model_id, Value: API key (None means "checked but not found") +/// +/// Uses parking_lot::RwLock which never poisons, avoiding potential panics +/// if a thread crashes while holding the lock. static API_KEY_CACHE: RwLock>>> = RwLock::new(None); /// Initialize the cache fn ensure_cache_initialized() { - let mut cache = API_KEY_CACHE.write().unwrap(); + let mut cache = API_KEY_CACHE.write(); if cache.is_none() { *cache = Some(HashMap::new()); } @@ -28,7 +36,7 @@ fn ensure_cache_initialized() { /// Check if we've already checked this model's key (regardless of result) fn is_cached(model_id: &str) -> bool { - let cache = API_KEY_CACHE.read().unwrap(); + let cache = API_KEY_CACHE.read(); cache .as_ref() .map(|c| c.contains_key(model_id)) @@ -37,7 +45,7 @@ fn is_cached(model_id: &str) -> bool { /// Get API key from cache (returns None if not cached OR if cached as "no key") fn get_cached_key(model_id: &str) -> Option { - let cache = API_KEY_CACHE.read().unwrap(); + let cache = API_KEY_CACHE.read(); cache .as_ref() .and_then(|c| c.get(model_id).cloned()) @@ -46,7 +54,7 @@ fn get_cached_key(model_id: &str) -> Option { /// Check if API key exists in cache fn has_cached_key(model_id: &str) -> Option { - let cache = API_KEY_CACHE.read().unwrap(); + let cache = API_KEY_CACHE.read(); cache .as_ref() .and_then(|c| c.get(model_id).map(|v| v.is_some())) @@ -55,7 +63,7 @@ fn has_cached_key(model_id: &str) -> Option { /// Store API key in cache fn cache_key(model_id: &str, api_key: &str) { ensure_cache_initialized(); - let mut cache = API_KEY_CACHE.write().unwrap(); + let mut cache = API_KEY_CACHE.write(); if let Some(c) = cache.as_mut() { c.insert(model_id.to_string(), Some(api_key.to_string())); } @@ -64,7 +72,7 @@ fn cache_key(model_id: &str, api_key: &str) { /// Mark that we checked and there's no key fn cache_no_key(model_id: &str) { ensure_cache_initialized(); - let mut cache = API_KEY_CACHE.write().unwrap(); + let mut cache = API_KEY_CACHE.write(); if let Some(c) = cache.as_mut() { c.insert(model_id.to_string(), None); } @@ -72,7 +80,7 @@ fn cache_no_key(model_id: &str) { /// Remove API key from cache fn uncache_key(model_id: &str) { - let mut cache = API_KEY_CACHE.write().unwrap(); + let mut cache = API_KEY_CACHE.write(); if let Some(c) = cache.as_mut() { c.remove(model_id); } diff --git a/src-tauri/src/features/shortcuts/manager.rs b/src-tauri/src/features/shortcuts/manager.rs index 69e5ac7..7cd25a2 100644 --- a/src-tauri/src/features/shortcuts/manager.rs +++ b/src-tauri/src/features/shortcuts/manager.rs @@ -1,12 +1,17 @@ use crate::features::shortcuts::utils::parse_shortcut; use crate::utils::logger; -use std::sync::{Arc, Mutex}; +use parking_lot::Mutex; +use std::sync::Arc; use tauri::{command, App, AppHandle, Manager, Result, State}; use tauri_plugin_global_shortcut::{ Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutEvent, ShortcutState, }; use tauri_plugin_store::StoreExt; +/// ShortcutManager uses parking_lot::Mutex instead of std::sync::Mutex because: +/// - No lock poisoning on panic (more resilient) +/// - Faster locking operations +/// - Consistent with other locks in the codebase pub struct ShortcutManager { pub voice_input_shortcut: Arc>>, pub push_to_talk_shortcut: Arc>>, @@ -77,20 +82,13 @@ pub fn register_voice_input_shortcut(app: &App) -> Result<()> { )?; // Store shortcuts in state + // parking_lot::Mutex never poisons, so lock() always succeeds let shortcut_state = app.state::(); - if let Ok(mut current) = shortcut_state.voice_input_shortcut.lock() { - *current = Some(voice_shortcut); - } - if let Ok(mut current) = shortcut_state.push_to_talk_shortcut.lock() { - *current = ptt_shortcut_opt; - } - if let Ok(mut current) = shortcut_state.paste_shortcut.lock() { - *current = Some(paste_shortcut); - } + *shortcut_state.voice_input_shortcut.lock() = Some(voice_shortcut); + *shortcut_state.push_to_talk_shortcut.lock() = ptt_shortcut_opt; + *shortcut_state.paste_shortcut.lock() = Some(paste_shortcut); // Escape shortcut is not registered here - it's registered dynamically when recording starts - if let Ok(mut current) = shortcut_state.escape_shortcut.lock() { - *current = None; - } + *shortcut_state.escape_shortcut.lock() = None; Ok(()) } @@ -234,10 +232,8 @@ pub async fn update_voice_input_shortcut( )); // Check if shortcuts are enabled - let shortcuts_enabled = *shortcut_state - .shortcuts_enabled - .lock() - .map_err(|e| format!("Failed to lock shortcuts_enabled: {}", e))?; + // parking_lot::Mutex never fails, so we can access directly + let shortcuts_enabled = *shortcut_state.shortcuts_enabled.lock(); if !shortcuts_enabled { logger::info("Shortcuts are disabled, skipping registration"); @@ -249,7 +245,8 @@ pub async fn update_voice_input_shortcut( .ok_or_else(|| format!("Failed to parse shortcut: {}", shortcut_str))?; // Unregister old shortcut if it exists - if let Ok(mut old_shortcut) = shortcut_state.voice_input_shortcut.lock() { + { + let mut old_shortcut = shortcut_state.voice_input_shortcut.lock(); if let Some(old) = old_shortcut.take() { logger::info("Unregistering old voice input shortcut"); let _ = app.global_shortcut().unregister(old); @@ -265,9 +262,7 @@ pub async fn update_voice_input_shortcut( .map_err(|e| format!("Failed to register voice input shortcut: {}", e))?; // Store the new shortcut - if let Ok(mut current) = shortcut_state.voice_input_shortcut.lock() { - *current = Some(shortcut_clone); - } + *shortcut_state.voice_input_shortcut.lock() = Some(shortcut_clone); logger::info("Voice input shortcut updated successfully"); Ok(()) @@ -283,10 +278,7 @@ pub async fn update_paste_shortcut( logger::info(&format!("Updating paste shortcut to: {}", shortcut_str)); // Check if shortcuts are enabled - let shortcuts_enabled = *shortcut_state - .shortcuts_enabled - .lock() - .map_err(|e| format!("Failed to lock shortcuts_enabled: {}", e))?; + let shortcuts_enabled = *shortcut_state.shortcuts_enabled.lock(); if !shortcuts_enabled { logger::info("Shortcuts are disabled, skipping registration"); @@ -298,7 +290,8 @@ pub async fn update_paste_shortcut( .ok_or_else(|| format!("Failed to parse shortcut: {}", shortcut_str))?; // Unregister old shortcut if it exists - if let Ok(mut old_shortcut) = shortcut_state.paste_shortcut.lock() { + { + let mut old_shortcut = shortcut_state.paste_shortcut.lock(); if let Some(old) = old_shortcut.take() { logger::info("Unregistering old paste shortcut"); let _ = app.global_shortcut().unregister(old); @@ -314,9 +307,7 @@ pub async fn update_paste_shortcut( .map_err(|e| format!("Failed to register paste shortcut: {}", e))?; // Store the new shortcut - if let Ok(mut current) = shortcut_state.paste_shortcut.lock() { - *current = Some(shortcut_clone); - } + *shortcut_state.paste_shortcut.lock() = Some(shortcut_clone); logger::info("Paste shortcut updated successfully"); Ok(()) @@ -346,30 +337,31 @@ pub async fn disable_global_shortcuts( logger::info("Disabling all global shortcuts"); // Unregister voice input shortcut - if let Ok(mut voice_shortcut) = shortcut_state.voice_input_shortcut.lock() { + { + let mut voice_shortcut = shortcut_state.voice_input_shortcut.lock(); if let Some(shortcut) = voice_shortcut.take() { let _ = app.global_shortcut().unregister(shortcut); } } // Unregister paste shortcut - if let Ok(mut paste_shortcut) = shortcut_state.paste_shortcut.lock() { + { + let mut paste_shortcut = shortcut_state.paste_shortcut.lock(); if let Some(shortcut) = paste_shortcut.take() { let _ = app.global_shortcut().unregister(shortcut); } } // Unregister escape shortcut - if let Ok(mut escape_shortcut) = shortcut_state.escape_shortcut.lock() { + { + let mut escape_shortcut = shortcut_state.escape_shortcut.lock(); if let Some(shortcut) = escape_shortcut.take() { let _ = app.global_shortcut().unregister(shortcut); } } // Set shortcuts as disabled - if let Ok(mut enabled) = shortcut_state.shortcuts_enabled.lock() { - *enabled = false; - } + *shortcut_state.shortcuts_enabled.lock() = false; logger::info("All global shortcuts disabled"); Ok(()) @@ -384,15 +376,11 @@ pub async fn enable_global_shortcuts( logger::info("Enabling all global shortcuts"); // Set shortcuts as enabled - if let Ok(mut enabled) = shortcut_state.shortcuts_enabled.lock() { - *enabled = true; - } + *shortcut_state.shortcuts_enabled.lock() = true; // Re-register voice input shortcut from settings let voice_input_shortcut = get_voice_input_shortcut_from_settings(&app); - if let Ok(parsed) = parse_shortcut(&voice_input_shortcut) - .ok_or_else(|| "Failed to parse voice input shortcut".to_string()) - { + if let Some(parsed) = parse_shortcut(&voice_input_shortcut) { let shortcut_clone = parsed.clone(); app.global_shortcut() .on_shortcut(parsed.clone(), move |app, shortcut, event| { @@ -400,16 +388,12 @@ pub async fn enable_global_shortcuts( }) .map_err(|e| format!("Failed to register voice input shortcut: {}", e))?; - if let Ok(mut current) = shortcut_state.voice_input_shortcut.lock() { - *current = Some(shortcut_clone); - } + *shortcut_state.voice_input_shortcut.lock() = Some(shortcut_clone); } // Re-register paste shortcut from settings - let paste_shortcut = get_paste_shortcut_from_settings(&app); - if let Ok(parsed) = - parse_shortcut(&paste_shortcut).ok_or_else(|| "Failed to parse paste shortcut".to_string()) - { + let paste_shortcut_str = get_paste_shortcut_from_settings(&app); + if let Some(parsed) = parse_shortcut(&paste_shortcut_str) { let shortcut_clone = parsed.clone(); app.global_shortcut() .on_shortcut(parsed.clone(), move |app, shortcut, event| { @@ -417,9 +401,7 @@ pub async fn enable_global_shortcuts( }) .map_err(|e| format!("Failed to register paste shortcut: {}", e))?; - if let Ok(mut current) = shortcut_state.paste_shortcut.lock() { - *current = Some(shortcut_clone); - } + *shortcut_state.paste_shortcut.lock() = Some(shortcut_clone); } // Re-register Escape shortcut @@ -431,9 +413,7 @@ pub async fn enable_global_shortcuts( }) .map_err(|e| format!("Failed to register escape shortcut: {}", e))?; - if let Ok(mut current) = shortcut_state.escape_shortcut.lock() { - *current = Some(escape_clone); - } + *shortcut_state.escape_shortcut.lock() = Some(escape_clone); logger::info("All global shortcuts enabled"); Ok(()) @@ -472,10 +452,7 @@ pub async fn register_ptt_shortcut( logger::info(&format!("Registering PTT shortcut: {}", shortcut_str)); // Check if shortcuts are enabled - let shortcuts_enabled = *shortcut_state - .shortcuts_enabled - .lock() - .map_err(|e| format!("Failed to lock shortcuts_enabled: {}", e))?; + let shortcuts_enabled = *shortcut_state.shortcuts_enabled.lock(); if !shortcuts_enabled { logger::info("Shortcuts are disabled, skipping PTT registration"); @@ -495,9 +472,7 @@ pub async fn register_ptt_shortcut( .map_err(|e| format!("Failed to register PTT shortcut: {}", e))?; // Store the new shortcut - if let Ok(mut current) = shortcut_state.push_to_talk_shortcut.lock() { - *current = Some(shortcut_clone); - } + *shortcut_state.push_to_talk_shortcut.lock() = Some(shortcut_clone); logger::info("PTT shortcut registered successfully"); Ok(()) @@ -512,7 +487,8 @@ pub async fn unregister_ptt_shortcut( logger::info("Unregistering PTT shortcut"); // Unregister PTT shortcut - if let Ok(mut ptt_shortcut) = shortcut_state.push_to_talk_shortcut.lock() { + { + let mut ptt_shortcut = shortcut_state.push_to_talk_shortcut.lock(); if let Some(shortcut) = ptt_shortcut.take() { let _ = app.global_shortcut().unregister(shortcut); } @@ -532,10 +508,7 @@ pub async fn update_ptt_shortcut( logger::info(&format!("Updating PTT shortcut to: {}", shortcut_str)); // Check if shortcuts are enabled - let shortcuts_enabled = *shortcut_state - .shortcuts_enabled - .lock() - .map_err(|e| format!("Failed to lock shortcuts_enabled: {}", e))?; + let shortcuts_enabled = *shortcut_state.shortcuts_enabled.lock(); if !shortcuts_enabled { logger::info("Shortcuts are disabled, skipping PTT update"); @@ -543,7 +516,8 @@ pub async fn update_ptt_shortcut( } // Unregister old shortcut - if let Ok(mut old_shortcut) = shortcut_state.push_to_talk_shortcut.lock() { + { + let mut old_shortcut = shortcut_state.push_to_talk_shortcut.lock(); if let Some(old) = old_shortcut.take() { logger::info("Unregistering old PTT shortcut"); let _ = app.global_shortcut().unregister(old); @@ -562,9 +536,7 @@ pub async fn update_ptt_shortcut( .map_err(|e| format!("Failed to register PTT shortcut: {}", e))?; // Store the new shortcut - if let Ok(mut current) = shortcut_state.push_to_talk_shortcut.lock() { - *current = Some(shortcut_clone); - } + *shortcut_state.push_to_talk_shortcut.lock() = Some(shortcut_clone); logger::info("PTT shortcut updated successfully"); Ok(()) @@ -579,10 +551,7 @@ pub async fn register_escape_shortcut( log::info!("Registering Escape shortcut for recording cancellation"); // Check if shortcuts are enabled - let shortcuts_enabled = *shortcut_state - .shortcuts_enabled - .lock() - .map_err(|e| format!("Failed to lock shortcuts_enabled: {}", e))?; + let shortcuts_enabled = *shortcut_state.shortcuts_enabled.lock(); if !shortcuts_enabled { log::info!("Shortcuts are disabled, skipping Escape registration"); @@ -601,9 +570,7 @@ pub async fn register_escape_shortcut( .map_err(|e| format!("Failed to register Escape shortcut: {}", e))?; // Store the shortcut - if let Ok(mut current) = shortcut_state.escape_shortcut.lock() { - *current = Some(escape_clone); - } + *shortcut_state.escape_shortcut.lock() = Some(escape_clone); log::info!("Escape shortcut registered successfully"); Ok(()) @@ -618,7 +585,8 @@ pub async fn unregister_escape_shortcut( log::info!("Unregistering Escape shortcut"); // Unregister Escape shortcut - if let Ok(mut escape_shortcut) = shortcut_state.escape_shortcut.lock() { + { + let mut escape_shortcut = shortcut_state.escape_shortcut.lock(); if let Some(shortcut) = escape_shortcut.take() { let _ = app.global_shortcut().unregister(shortcut); log::info!("Escape shortcut unregistered"); diff --git a/src-tauri/src/features/window/mod.rs b/src-tauri/src/features/window/mod.rs index 435fbc8..ccd0f87 100644 --- a/src-tauri/src/features/window/mod.rs +++ b/src-tauri/src/features/window/mod.rs @@ -2,7 +2,7 @@ pub mod pill_window; pub mod toast_window; pub use pill_window::{ - position_pill_window_on_current_screen, set_pill_monitor_active, setup_pill_window, - start_pill_window_monitor, + configure_pill_window_for_mode, position_pill_window_on_current_screen, + set_pill_monitor_active, setup_pill_window, start_pill_window_monitor, }; pub use toast_window::{setup_toast_window, show_toast, ToastType}; diff --git a/src-tauri/src/features/window/pill_window.rs b/src-tauri/src/features/window/pill_window.rs index c60c575..e7ce492 100644 --- a/src-tauri/src/features/window/pill_window.rs +++ b/src-tauri/src/features/window/pill_window.rs @@ -1,3 +1,4 @@ +use crate::types::settings::VoiceInputDisplayMode; use tauri::AppHandle; #[cfg(target_os = "macos")] @@ -8,6 +9,39 @@ use std::sync::Arc; #[cfg(target_os = "macos")] static MONITOR_ACTIVE: std::sync::OnceLock> = std::sync::OnceLock::new(); +/// Position type for the pill window +#[derive(Debug, Clone, Copy)] +pub enum PillPosition { + BottomCenter { offset: f64 }, + TopCenter { offset: f64 }, +} + +/// Window configuration per display mode +#[derive(Debug, Clone, Copy)] +pub struct PillWindowConfig { + pub width: f64, + pub height: f64, + pub position: PillPosition, +} + +impl PillWindowConfig { + /// Get window configuration for a specific display mode + pub fn for_mode(mode: &VoiceInputDisplayMode) -> Self { + match mode { + VoiceInputDisplayMode::Standard => Self { + width: 240.0, + height: 40.0, + position: PillPosition::BottomCenter { offset: 16.0 }, + }, + VoiceInputDisplayMode::Minimal => Self { + width: 160.0, + height: 40.0, + position: PillPosition::BottomCenter { offset: 16.0 }, + }, + } + } +} + /// Screen frame data extracted from NSScreen #[cfg(target_os = "macos")] #[derive(Debug, Clone, Copy)] @@ -40,88 +74,98 @@ struct ScreenQueryResult { /// Safely query screen information on the main thread. /// /// Returns None if called from a non-main thread or if screen access fails. +/// IMPORTANT: We return None instead of using new_unchecked() because: +/// - new_unchecked() violates safety contract when not on main thread +/// - Callers must handle None gracefully (use default position) #[cfg(target_os = "macos")] fn query_screen_info_for_mouse() -> Option { use objc2::MainThreadMarker; use objc2_app_kit::{NSEvent, NSScreen}; // Try to get main thread marker - returns None if not on main thread - // This is safer than new_unchecked() which assumes we're on main thread - let mtm = match MainThreadMarker::new() { - Some(m) => m, - None => { - // We're not on the main thread - use unchecked but log warning - log::debug!("Screen query not on main thread - using unchecked marker"); - // SAFETY: Tauri's event loop runs on main thread, and this is called - // from window positioning which happens on the main thread via Tauri - unsafe { MainThreadMarker::new_unchecked() } - } + // NEVER use new_unchecked() - it can cause undefined behavior + let mtm = MainThreadMarker::new()?; + + let mouse_location = NSEvent::mouseLocation(); + let screens = NSScreen::screens(mtm); + let main_screen = NSScreen::mainScreen(mtm)?; + let main_frame = main_screen.frame(); + + let main_screen_frame = ScreenFrame { + origin_x: main_frame.origin.x, + origin_y: main_frame.origin.y, + width: main_frame.size.width, + height: main_frame.size.height, }; - unsafe { - let mouse_location = NSEvent::mouseLocation(); - let screens = NSScreen::screens(mtm); - let main_screen = NSScreen::mainScreen(mtm)?; - let main_frame = main_screen.frame(); - - let main_screen_frame = ScreenFrame { - origin_x: main_frame.origin.x, - origin_y: main_frame.origin.y, - width: main_frame.size.width, - height: main_frame.size.height, - }; - - // Find screen containing mouse cursor - let screen_count = screens.len(); - let mut target_visible_frame = main_screen.visibleFrame(); - - for i in 0..screen_count { - let screen = screens.objectAtIndex(i); - let frame = screen.frame(); - - if mouse_location.x >= frame.origin.x - && mouse_location.x < frame.origin.x + frame.size.width - && mouse_location.y >= frame.origin.y - && mouse_location.y < frame.origin.y + frame.size.height - { - target_visible_frame = screen.visibleFrame(); - break; - } + // Find screen containing mouse cursor + let screen_count = screens.len(); + let mut target_visible_frame = main_screen.visibleFrame(); + + for i in 0..screen_count { + let screen = screens.objectAtIndex(i); + let frame = screen.frame(); + + if mouse_location.x >= frame.origin.x + && mouse_location.x < frame.origin.x + frame.size.width + && mouse_location.y >= frame.origin.y + && mouse_location.y < frame.origin.y + frame.size.height + { + target_visible_frame = screen.visibleFrame(); + break; } - - Some(ScreenQueryResult { - main_screen_frame, - target_visible_frame: ScreenFrame { - origin_x: target_visible_frame.origin.x, - origin_y: target_visible_frame.origin.y, - width: target_visible_frame.size.width, - height: target_visible_frame.size.height, - }, - mouse_x: mouse_location.x, - mouse_y: mouse_location.y, - }) } + + Some(ScreenQueryResult { + main_screen_frame, + target_visible_frame: ScreenFrame { + origin_x: target_visible_frame.origin.x, + origin_y: target_visible_frame.origin.y, + width: target_visible_frame.size.width, + height: target_visible_frame.size.height, + }, + mouse_x: mouse_location.x, + mouse_y: mouse_location.y, + }) } -/// Calculate pill window position from screen info. +/// Calculate pill window position from screen info using default config. #[cfg(target_os = "macos")] fn calculate_pill_position(screen_info: &ScreenQueryResult) -> (f64, f64) { - const PILL_WIDTH: f64 = 240.0; - const PILL_HEIGHT: f64 = 40.0; - const BOTTOM_OFFSET: f64 = 16.0; + calculate_pill_position_with_config( + screen_info, + &PillWindowConfig::for_mode(&VoiceInputDisplayMode::Standard), + ) +} +/// Calculate pill window position from screen info with custom config. +#[cfg(target_os = "macos")] +fn calculate_pill_position_with_config( + screen_info: &ScreenQueryResult, + config: &PillWindowConfig, +) -> (f64, f64) { let visible = &screen_info.target_visible_frame; let main = &screen_info.main_screen_frame; // Center horizontally on visible area - let x = visible.origin_x + (visible.width - PILL_WIDTH) / 2.0; - - // Position at bottom of visible area (above dock if present) - let macos_window_top_y = visible.origin_y + BOTTOM_OFFSET + PILL_HEIGHT; - let y = main.height - macos_window_top_y; + let x = visible.origin_x + (visible.width - config.width) / 2.0; + + // Calculate vertical position based on mode + let y = match config.position { + PillPosition::BottomCenter { offset } => { + // Position at bottom of visible area (above dock if present) + let macos_window_top_y = visible.origin_y + offset + config.height; + main.height - macos_window_top_y + } + PillPosition::TopCenter { offset } => { + // Position at top of visible area (below menu bar) + let macos_window_top_y = visible.origin_y + visible.height - offset; + main.height - macos_window_top_y + } + }; log::debug!( - "Pill position: mouse=({:.0}, {:.0}), visible={}x{} at ({:.0}, {:.0}), result=({:.0}, {:.0})", + "Pill position: mouse=({:.0}, {:.0}), visible={}x{} at ({:.0}, {:.0}), result=({:.0}, {:.0}), mode={:?}", screen_info.mouse_x, screen_info.mouse_y, visible.width, @@ -129,7 +173,8 @@ fn calculate_pill_position(screen_info: &ScreenQueryResult) -> (f64, f64) { visible.origin_x, visible.origin_y, x, - y + y, + config.position ); (x, y) @@ -157,6 +202,7 @@ fn is_monitor_active() -> bool { /// Positions the voice input window on the screen containing the mouse cursor. /// /// Uses safe screen query helpers to avoid crashes from objc2 thread safety issues. +/// If not on main thread, silently skips positioning (window keeps current position). pub fn position_pill_window_on_current_screen(app: &AppHandle) -> tauri::Result<()> { use tauri::Manager; @@ -164,13 +210,13 @@ pub fn position_pill_window_on_current_screen(app: &AppHandle) -> tauri::Result< .get_webview_window("voice-input") .ok_or_else(|| tauri::Error::WindowNotFound)?; - // Query screen info safely - let screen_info = query_screen_info_for_mouse().ok_or_else(|| { - tauri::Error::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "Failed to query screen information", - )) - })?; + // Query screen info safely - returns None if not on main thread + let Some(screen_info) = query_screen_info_for_mouse() else { + // Not on main thread - skip positioning, window keeps current position + // This is safe because window operations are still thread-safe in Tauri + log::debug!("position_pill_window: not on main thread, skipping reposition"); + return Ok(()); + }; // Calculate position using extracted data (no unsafe code needed here) let (pos_x, pos_y) = calculate_pill_position(&screen_info); @@ -183,35 +229,87 @@ pub fn position_pill_window_on_current_screen(app: &AppHandle) -> tauri::Result< Ok(()) } +#[cfg(target_os = "macos")] +/// Configure and position the pill window for a specific display mode. +/// +/// This resizes the window and positions it according to the mode's configuration. +/// If not on main thread, still resizes but skips repositioning. +pub fn configure_pill_window_for_mode( + app: &AppHandle, + mode: &VoiceInputDisplayMode, +) -> tauri::Result<()> { + use tauri::Manager; + + let window = app + .get_webview_window("voice-input") + .ok_or_else(|| tauri::Error::WindowNotFound)?; + + let config = PillWindowConfig::for_mode(mode); + + // Resize window - this is thread-safe in Tauri + window.set_size(tauri::Size::Logical(tauri::LogicalSize { + width: config.width, + height: config.height, + }))?; + + // Query screen info safely - returns None if not on main thread + let Some(screen_info) = query_screen_info_for_mouse() else { + // Not on main thread - skip positioning, window keeps current position + log::debug!( + "configure_pill_window: not on main thread, skipping position (size: {}x{})", + config.width, + config.height + ); + return Ok(()); + }; + + // Calculate position using mode-specific config + let (pos_x, pos_y) = calculate_pill_position_with_config(&screen_info, &config); + + window.set_position(tauri::Position::Logical(tauri::LogicalPosition { + x: pos_x, + y: pos_y, + }))?; + + log::debug!( + "Configured pill window for mode {:?}: {}x{} at ({:.0}, {:.0})", + mode, + config.width, + config.height, + pos_x, + pos_y + ); + + Ok(()) +} + /// Query all screen visible frames safely. +/// Returns empty vec if not on main thread (graceful degradation). #[cfg(target_os = "macos")] fn query_all_screen_frames() -> Vec { use objc2::MainThreadMarker; use objc2_app_kit::NSScreen; - // SAFETY: This is called from async context but screen queries should be safe - // as long as we don't hold references across await points - let mtm = match MainThreadMarker::new() { - Some(m) => m, - None => unsafe { MainThreadMarker::new_unchecked() }, + // Return empty if not on main thread - never use new_unchecked() + let Some(mtm) = MainThreadMarker::new() else { + log::debug!("query_all_screen_frames: not on main thread, returning empty"); + return Vec::new(); }; - unsafe { - let screens = NSScreen::screens(mtm); - let mut frames = Vec::with_capacity(screens.len()); - - for i in 0..screens.len() { - let screen = screens.objectAtIndex(i); - let visible = screen.visibleFrame(); - frames.push(ScreenFrame { - origin_x: visible.origin.x, - origin_y: visible.origin.y, - width: visible.size.width, - height: visible.size.height, - }); - } - frames + let screens = NSScreen::screens(mtm); + let mut frames = Vec::with_capacity(screens.len()); + + for i in 0..screens.len() { + let screen = screens.objectAtIndex(i); + let visible = screen.visibleFrame(); + frames.push(ScreenFrame { + origin_x: visible.origin.x, + origin_y: visible.origin.y, + width: visible.size.width, + height: visible.size.height, + }); } + frames } /// Check if screen frames have changed significantly. @@ -288,6 +386,7 @@ pub fn start_pill_window_monitor(app: AppHandle) { } /// Get initial pill window position on main screen. +/// Returns None if not on main thread (should only be called during setup on main thread). #[cfg(target_os = "macos")] fn get_initial_pill_position() -> Option<(f64, f64)> { use objc2::MainThreadMarker; @@ -297,26 +396,18 @@ fn get_initial_pill_position() -> Option<(f64, f64)> { const PILL_HEIGHT: f64 = 40.0; const BOTTOM_OFFSET: f64 = 16.0; - let mtm = match MainThreadMarker::new() { - Some(m) => m, - None => { - // During app setup, we should be on main thread - // but use unchecked as fallback - unsafe { MainThreadMarker::new_unchecked() } - } - }; + // Must be on main thread - never use new_unchecked() + let mtm = MainThreadMarker::new()?; - unsafe { - let screen = NSScreen::mainScreen(mtm)?; - let screen_frame = screen.frame(); - let visible_frame = screen.visibleFrame(); + let screen = NSScreen::mainScreen(mtm)?; + let screen_frame = screen.frame(); + let visible_frame = screen.visibleFrame(); - let x = visible_frame.origin.x + (visible_frame.size.width - PILL_WIDTH) / 2.0; - let macos_y = visible_frame.origin.y + BOTTOM_OFFSET; - let y = screen_frame.size.height - macos_y - PILL_HEIGHT; + let x = visible_frame.origin.x + (visible_frame.size.width - PILL_WIDTH) / 2.0; + let macos_y = visible_frame.origin.y + BOTTOM_OFFSET; + let y = screen_frame.size.height - macos_y - PILL_HEIGHT; - Some((x, y)) - } + Some((x, y)) } #[cfg(target_os = "macos")] diff --git a/src-tauri/src/features/window/toast_window.rs b/src-tauri/src/features/window/toast_window.rs index 8e83d31..4480541 100644 --- a/src-tauri/src/features/window/toast_window.rs +++ b/src-tauri/src/features/window/toast_window.rs @@ -85,8 +85,15 @@ fn position_toast_window(app: &AppHandle) -> tauri::Result<()> { .get_webview_window("toast") .ok_or_else(|| tauri::Error::WindowNotFound)?; - let (pos_x, pos_y) = unsafe { - let mtm = MainThreadMarker::new_unchecked(); + // Safely get MainThreadMarker - don't use unchecked version + // If we're not on the main thread, fall back to default position + let Some(mtm) = MainThreadMarker::new() else { + log::warn!("position_toast_window called from non-main thread, using default position"); + return Ok(()); + }; + + let (pos_x, pos_y) = { + // No longer need unsafe block since we verified main thread // Get the mouse location to find current screen let mouse_location = NSEvent::mouseLocation(); @@ -114,7 +121,7 @@ fn position_toast_window(app: &AppHandle) -> tauri::Result<()> { let toast_width = 360.0; let toast_height = 72.0; - let pill_width = 240.0; + let _pill_width = 240.0; // Used for layout reference let pill_height = 40.0; let bottom_offset = 16.0; let gap = 12.0; // Gap between pill and toast @@ -151,15 +158,23 @@ pub fn setup_toast_window(app: &AppHandle) -> tauri::Result<()> { log::info!("Setting up toast window"); - let (toast_x, toast_y) = unsafe { - let mtm = MainThreadMarker::new_unchecked(); + // Safely get MainThreadMarker - setup should always be on main thread + // but we check to be safe and avoid crashes + let mtm = MainThreadMarker::new().ok_or_else(|| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + "setup_toast_window must be called from main thread", + )) + })?; + + let (toast_x, toast_y) = { let screen = NSScreen::mainScreen(mtm).expect("Failed to get main screen"); let screen_frame = screen.frame(); let visible_frame = screen.visibleFrame(); let toast_width = 360.0; let toast_height = 72.0; - let pill_width = 240.0; + let _pill_width = 240.0; // Used for layout calculations let pill_height = 40.0; let bottom_offset = 16.0; let gap = 12.0; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f7bccad..7a8cdbd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -67,6 +67,32 @@ fn get_device_id() -> Result { machine_uid::get().map_err(|e| format!("Failed to get device ID: {}", e)) } +/// Invalidate the settings cache to reload from in-memory store. +/// +/// IMPORTANT: This does NOT reload from disk. The frontend writes to the store +/// first, then calls this to update the cache. This design prevents deadlocks +/// when settings are changed while recording is active. +/// +/// The cache uses atomic swap semantics, so this operation is very fast and +/// won't block readers (e.g., the audio thread reading settings). +#[tauri::command] +async fn invalidate_settings_cache(app: tauri::AppHandle) -> Result<(), String> { + // Run on blocking thread pool to avoid any potential main thread blocking + let result = tokio::task::spawn_blocking(move || { + if let Some(cache) = + app.try_state::>() + { + cache.invalidate(&app)?; + log::debug!("Settings cache invalidated via command"); + } + Ok::<(), String>(()) + }) + .await + .map_err(|e| format!("Task failed: {}", e))?; + + result +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let app_version = env!("CARGO_PKG_VERSION"); @@ -382,6 +408,7 @@ pub fn run() { get_recording_audio_path, // System preferences set_show_in_dock, + invalidate_settings_cache, // Data export/import export_all_data, import_all_data, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 81fbc2b..533f70f 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -24,10 +24,19 @@ const TRAY_LANGUAGES: &[(&str, &str)] = &[ /// Sets up the system tray icon and menu pub fn setup_tray(app: &App, model_manager_cleanup: Arc>) -> Result<()> { // Get available audio devices - let runtime = tokio::runtime::Runtime::new().unwrap(); - let devices = runtime - .block_on(enumerate_audio_devices()) - .unwrap_or_default(); + // IMPORTANT: We use a separate thread to avoid nested runtime issues. + // Creating a tokio::runtime::Runtime::new().block_on() inside Tauri's + // existing runtime can cause deadlocks/hangs. + let devices = std::thread::spawn(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok(); + rt.and_then(|rt| rt.block_on(enumerate_audio_devices()).ok()) + .unwrap_or_default() + }) + .join() + .unwrap_or_default(); // Get current microphone device from settings let store = app.store("settings").map_err(|e| { @@ -239,7 +248,9 @@ async fn rebuild_tray_menu( let devices = enumerate_audio_devices().await.unwrap_or_default(); // Get current microphone device from settings - // Reload the store to ensure we have fresh data + // IMPORTANT: Do NOT call store.reload() here! + // The store already has the latest data in memory. + // Calling reload() is blocking I/O and can cause deadlocks during recording. let store = app.store("settings").map_err(|e| { tauri::Error::Io(std::io::Error::new( std::io::ErrorKind::Other, @@ -247,16 +258,6 @@ async fn rebuild_tray_menu( )) })?; - // Force reload from disk to get latest changes - log::info!("Reloading store from disk..."); - store.reload().map_err(|e| { - tauri::Error::Io(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Failed to reload store: {}", e), - )) - })?; - log::info!("Store reloaded successfully"); - let current_device_id = store.get("settings").and_then(|settings| { settings .get("voiceInput") @@ -788,13 +789,29 @@ fn handle_tray_event( } } "quit" => { - // Cleanup local model before exit - let runtime = tokio::runtime::Runtime::new().unwrap(); - runtime.block_on(async { - let mut manager = model_manager_cleanup.lock().await; - manager.unload_model(); - logger::info("Local model stopped on app exit"); - }); + // Cleanup local model before exit using a separate thread + // to avoid nested runtime issues + let model_manager = model_manager_cleanup.clone(); + std::thread::spawn(move || { + if let Ok(rt) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + rt.block_on(async { + if let Ok(mut manager) = tokio::time::timeout( + std::time::Duration::from_secs(2), + model_manager.lock(), + ) + .await + { + manager.unload_model(); + logger::info("Local model stopped on app exit"); + } + }); + } + }) + .join() + .ok(); app.exit(0); } _ => {} diff --git a/src-tauri/src/types/settings.rs b/src-tauri/src/types/settings.rs index 2c3913a..2d4bb5c 100644 --- a/src-tauri/src/types/settings.rs +++ b/src-tauri/src/types/settings.rs @@ -9,6 +9,16 @@ pub struct OnboardingSettings { pub completed: bool, } +/// Voice input display mode +#[derive(Debug, Clone, Serialize, Deserialize, TS, Default, PartialEq)] +#[ts(export, export_to = "../../src/features/settings/types/generated/")] +#[serde(rename_all = "lowercase")] +pub enum VoiceInputDisplayMode { + #[default] + Standard, + Minimal, +} + /// Voice input settings #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "../../src/features/settings/types/generated/")] @@ -18,6 +28,8 @@ pub struct VoiceInputSettings { pub microphone_device_id: Option, pub enable_push_to_talk: bool, pub push_to_talk_shortcut: String, + #[serde(default)] + pub display_mode: VoiceInputDisplayMode, } /// Transcription settings @@ -94,6 +106,7 @@ impl Default for Settings { microphone_device_id: None, enable_push_to_talk: false, push_to_talk_shortcut: "Alt+R".to_string(), + display_mode: VoiceInputDisplayMode::default(), }, transcription: TranscriptionSettings { language: "en".to_string(), diff --git a/src/features/settings/components/panels/general-panel.tsx b/src/features/settings/components/panels/general-panel.tsx index b8b6247..e60bc61 100644 --- a/src/features/settings/components/panels/general-panel.tsx +++ b/src/features/settings/components/panels/general-panel.tsx @@ -1,43 +1,14 @@ -import { AlertTriangle } from 'lucide-react' - -import { Switch } from '@/components/ui/switch' -import { useModelsStore } from '@/features/models/store' import { useAudioDevices } from '@/hooks/use-audio-devices' -import { getLanguageByCode } from '../../data/languages' import { useSettingsStore } from '../../store' -import { LanguageSelector } from '../language-selector' import { MicrophoneSelector } from '../microphone-selector' -import { SettingsInfoTooltip } from '../settings-info-tooltip' -import { ThemeSelector } from '../theme-selector' +import { VisualDisplayModeSelector } from '../visual-display-mode-selector' +import { VisualThemeSelector } from '../visual-theme-selector' import { SettingsPanel, SettingItem, SettingsSection } from './settings-panel' export function GeneralPanel() { - const { - settings, - setEnablePushToTalk, - setTranslateToEnglish, - setAutoDetectLanguage, - } = useSettingsStore() + const { settings } = useSettingsStore() const { devices } = useAudioDevices() - const { models } = useModelsStore() - - const selectedLanguage = getLanguageByCode(settings.transcription.language) - const languageDescription = settings.transcription.autoDetectLanguage - ? 'Auto-detect (Whisper will identify the language)' - : selectedLanguage - ? `${selectedLanguage.name} (${selectedLanguage.nativeName})` - : 'English' - - // Check if the selected STT model supports the selected language - const selectedSttModel = models.find( - m => m.purpose === 'speech-to-text' && m.isSelected - ) - const isEnglishOnly = selectedSttModel?.languageSupport === 'english_only' - const isNonEnglishLanguage = settings.transcription.language !== 'en' - const isAutoDetect = settings.transcription.autoDetectLanguage - const hasLanguageCompatibilityIssue = - isEnglishOnly && isNonEnglishLanguage && !isAutoDetect // Get the currently selected microphone name for description const selectedDeviceId = settings.voiceInput.microphoneDeviceId @@ -62,116 +33,21 @@ export function GeneralPanel() { description={microphoneDescription} action={} /> + + - } - info={ - - } - /> - - } - disabled={isAutoDetect} - info={ - - } - /> - - - } - disabled={isEnglishOnly} - info={ - - } - /> - - - } - disabled={isEnglishOnly || !isNonEnglishLanguage || isAutoDetect} - info={ - - } + title="Voice input display" + description="Choose how the voice input window appears during recording" + action={} + layout="stacked" /> - {isEnglishOnly && settings.transcription.translateToEnglish && ( -
- -
-

- Translation requires a multilingual model -

-

- Your current model ({selectedSttModel?.name}) only supports - English. Please select a multilingual model like Whisper Base, - Small, Medium, or Large for translation. -

-
-
- )} - - {hasLanguageCompatibilityIssue && ( -
- -
-

- Model not compatible with {selectedLanguage?.name} -

-

- Your current model ({selectedSttModel?.name}) only supports - English. Please select a multilingual model like Whisper Base, - Small, Medium, or Large for {selectedLanguage?.name}{' '} - transcription. -

-
-
- )} - } + action={} + layout="stacked" />
diff --git a/src/features/settings/components/panels/settings-panel.tsx b/src/features/settings/components/panels/settings-panel.tsx index cfec5d5..8a70775 100644 --- a/src/features/settings/components/panels/settings-panel.tsx +++ b/src/features/settings/components/panels/settings-panel.tsx @@ -35,6 +35,8 @@ interface SettingItemProps { children?: ReactNode disabled?: boolean info?: ReactNode + /** Use 'stacked' layout to place action below title/description */ + layout?: 'inline' | 'stacked' } export function SettingItem({ @@ -44,7 +46,45 @@ export function SettingItem({ children, disabled, info, + layout = 'inline', }: SettingItemProps) { + if (layout === 'stacked') { + return ( +
+
+
+

+ {title} +

+ {info &&
{info}
} +
+ {description && ( +

+ {description} +

+ )} +
+ {action &&
{action}
} + {children &&
{children}
} +
+ ) + } + return (
+ + } + info={ + + } + /> + {settings.voiceInput.enablePushToTalk && ( m.purpose === 'speech-to-text' && m.isSelected + ) + const isEnglishOnly = selectedSttModel?.languageSupport === 'english_only' + const isNonEnglishLanguage = settings.transcription.language !== 'en' + const isAutoDetect = settings.transcription.autoDetectLanguage + const hasLanguageCompatibilityIssue = + isEnglishOnly && isNonEnglishLanguage && !isAutoDetect + const handleAutoPasteToggle = async (checked: boolean) => { setAutoPasteLoading(true) try { @@ -55,6 +81,95 @@ export function TranscriptionPanel() { title="Transcription" description="Customize how your transcriptions are processed and saved" > + + } + disabled={isAutoDetect} + info={ + + } + /> + + + } + disabled={isEnglishOnly} + info={ + + } + /> + + + } + disabled={isEnglishOnly || !isNonEnglishLanguage || isAutoDetect} + info={ + + } + /> + + {isEnglishOnly && settings.transcription.translateToEnglish && ( +
+ +
+

+ Translation requires a multilingual model +

+

+ Your current model ({selectedSttModel?.name}) only supports + English. Please select a multilingual model like Whisper Base, + Small, Medium, or Large for translation. +

+
+
+ )} + + {hasLanguageCompatibilityIssue && ( +
+ +
+

+ Model not compatible with {selectedLanguage?.name} +

+

+ Your current model ({selectedSttModel?.name}) only supports + English. Please select a multilingual model like Whisper Base, + Small, Medium, or Large for {selectedLanguage?.name}{' '} + transcription. +

+
+
+ )} +
+ -
+
-
+
diff --git a/src/features/settings/components/theme-selector.tsx b/src/features/settings/components/theme-selector.tsx deleted file mode 100644 index 847670f..0000000 --- a/src/features/settings/components/theme-selector.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Monitor, Moon, Sun } from 'lucide-react' - -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { useTheme } from '@/providers/theme-provider' - -export function ThemeSelector() { - const { theme, setTheme } = useTheme() - - const getThemeLabel = () => { - switch (theme) { - case 'light': - return 'Light' - case 'dark': - return 'Dark' - case 'system': - return 'System' - } - } - - const getThemeIcon = () => { - switch (theme) { - case 'light': - return - case 'dark': - return - case 'system': - return - } - } - - return ( - - - - - - setTheme('light')}> - - Light - - setTheme('dark')}> - - Dark - - setTheme('system')}> - - System - - - - ) -} diff --git a/src/features/settings/components/visual-display-mode-selector.tsx b/src/features/settings/components/visual-display-mode-selector.tsx new file mode 100644 index 0000000..beb7ed3 --- /dev/null +++ b/src/features/settings/components/visual-display-mode-selector.tsx @@ -0,0 +1,98 @@ +import { Check } from 'lucide-react' + +import { cn } from '@/lib/cn' + +import { useSettingsStore } from '../store' + +import type { VoiceInputDisplayMode } from '../types/generated' + +interface DisplayModeOption { + value: VoiceInputDisplayMode + label: string +} + +const displayModeOptions: DisplayModeOption[] = [ + { value: 'standard', label: 'Standard' }, + { value: 'minimal', label: 'Minimal' }, +] + +export function VisualDisplayModeSelector() { + const { settings, setVoiceInputDisplayMode } = useSettingsStore() + const currentMode = settings.voiceInput.displayMode ?? 'standard' + + return ( +
+ {displayModeOptions.map(option => ( + + ))} +
+ ) +} + +function DisplayModePreview({ variant }: { variant: VoiceInputDisplayMode }) { + const isStandard = variant === 'standard' + + // Pill container dimensions based on mode + const pillWidth = isStandard ? 'w-16' : 'w-12' + + return ( +
+ {isStandard && ( + // Cancel button (X) +
+
+
+ )} + + {/* Waveform bars */} +
+
+
+
+
+
+
+ + {isStandard && ( + // Stop button (square) +
+ )} +
+ ) +} diff --git a/src/features/settings/components/visual-theme-selector.tsx b/src/features/settings/components/visual-theme-selector.tsx new file mode 100644 index 0000000..3e9dc15 --- /dev/null +++ b/src/features/settings/components/visual-theme-selector.tsx @@ -0,0 +1,115 @@ +import { Check } from 'lucide-react' + +import { cn } from '@/lib/cn' +import { useTheme } from '@/providers/theme-provider' + +type Theme = 'light' | 'dark' | 'system' + +interface ThemeOption { + value: Theme + label: string +} + +const themeOptions: ThemeOption[] = [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'system', label: 'System' }, +] + +export function VisualThemeSelector() { + const { theme, setTheme } = useTheme() + + return ( +
+ {themeOptions.map(option => ( + + ))} +
+ ) +} + +function ThemePreview({ variant }: { variant: Theme }) { + const isLight = variant === 'light' + const isSystem = variant === 'system' + + if (isSystem) { + // System theme shows split view + return ( +
+ {/* Light half */} +
+
+
+
+
+
+
+
+
+ {/* Dark half */} +
+
+
+
+
+
+
+
+
+
+ ) + } + + const bgColor = isLight ? 'bg-gray-100' : 'bg-zinc-900' + const dotColor = isLight ? 'bg-gray-300' : 'bg-zinc-700' + const panelBg = isLight ? 'bg-white' : 'bg-zinc-800' + + return ( +
+ {/* Window chrome dots */} +
+
+
+
+
+ {/* Title bar accent */} +
+ {/* Content lines */} +
+
+
+
+
+
+
+
+ ) +} diff --git a/src/features/settings/schema.ts b/src/features/settings/schema.ts index 2940507..053d8d1 100644 --- a/src/features/settings/schema.ts +++ b/src/features/settings/schema.ts @@ -5,6 +5,7 @@ export type { Settings, OnboardingSettings, VoiceInputSettings, + VoiceInputDisplayMode, TranscriptionSettings, ShortcutsSettings, SystemSettings, @@ -21,6 +22,7 @@ export const defaultSettings: Settings = { microphoneDeviceId: null, enablePushToTalk: false, pushToTalkShortcut: 'Alt+R', + displayMode: 'standard', }, transcription: { language: 'en', diff --git a/src/features/settings/store.ts b/src/features/settings/store.ts index 6adadaf..c48e172 100644 --- a/src/features/settings/store.ts +++ b/src/features/settings/store.ts @@ -4,7 +4,12 @@ import { create } from 'zustand' import { storeAnalytics } from '@/lib/analytics' -import { AiProcessingSettings, defaultSettings, type Settings } from './schema' +import { + AiProcessingSettings, + defaultSettings, + type Settings, + type VoiceInputDisplayMode, +} from './schema' import type { SettingsStore } from './types' @@ -54,6 +59,7 @@ export const useSettingsStore = create((set, get) => ({ storedSettings?.voiceInput?.enablePushToTalk ?? false, pushToTalkShortcut: storedSettings?.voiceInput?.pushToTalkShortcut ?? 'Alt+R', + displayMode: storedSettings?.voiceInput?.displayMode ?? 'standard', }, transcription: { language: storedSettings?.transcription?.language ?? 'en', @@ -173,6 +179,28 @@ export const useSettingsStore = create((set, get) => ({ } }, + setVoiceInputDisplayMode: async (mode: VoiceInputDisplayMode) => { + try { + const store = await getTauriStore() + const newSettings = { + ...get().settings, + voiceInput: { + ...get().settings.voiceInput, + displayMode: mode, + }, + } + await store.set('settings', newSettings) + await store.save() + set({ settings: newSettings }) + + // Invalidate Rust cache so the new mode is used on next recording + await invoke('invalidate_settings_cache') + storeAnalytics.trackSettingChange('voiceInputDisplayMode', mode) + } catch (error) { + console.error('Error saving voice input display mode:', error) + } + }, + setTranscriptionLanguage: async (language: string) => { try { const store = await getTauriStore() diff --git a/src/features/settings/types.ts b/src/features/settings/types.ts index 12e4e0e..4cd255b 100644 --- a/src/features/settings/types.ts +++ b/src/features/settings/types.ts @@ -1,4 +1,4 @@ -import { type Settings } from './schema' +import { type Settings, type VoiceInputDisplayMode } from './schema' export interface SettingsStore { settings: Settings @@ -7,6 +7,7 @@ export interface SettingsStore { setOnboardingComplete: (completed: boolean) => Promise setVoiceInputShortcut: (shortcut: string) => Promise setMicrophoneDevice: (deviceId: string | null) => Promise + setVoiceInputDisplayMode: (mode: VoiceInputDisplayMode) => Promise setTranscriptionLanguage: (language: string) => Promise setAutoPaste: (enabled: boolean) => Promise setAutoCopyToClipboard: (enabled: boolean) => Promise diff --git a/src/features/settings/types/generated/VoiceInputDisplayMode.ts b/src/features/settings/types/generated/VoiceInputDisplayMode.ts new file mode 100644 index 0000000..250a19f --- /dev/null +++ b/src/features/settings/types/generated/VoiceInputDisplayMode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Voice input display mode + */ +export type VoiceInputDisplayMode = "standard" | "minimal"; diff --git a/src/features/settings/types/generated/VoiceInputSettings.ts b/src/features/settings/types/generated/VoiceInputSettings.ts index 5f63db7..80c7d26 100644 --- a/src/features/settings/types/generated/VoiceInputSettings.ts +++ b/src/features/settings/types/generated/VoiceInputSettings.ts @@ -1,6 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { VoiceInputDisplayMode } from "./VoiceInputDisplayMode"; /** * Voice input settings */ -export type VoiceInputSettings = { shortcut: string, microphoneDeviceId: string | null, enablePushToTalk: boolean, pushToTalkShortcut: string, }; +export type VoiceInputSettings = { shortcut: string, microphoneDeviceId: string | null, enablePushToTalk: boolean, pushToTalkShortcut: string, displayMode: VoiceInputDisplayMode, }; diff --git a/src/features/settings/types/generated/index.ts b/src/features/settings/types/generated/index.ts index 32873ab..730487d 100644 --- a/src/features/settings/types/generated/index.ts +++ b/src/features/settings/types/generated/index.ts @@ -4,6 +4,7 @@ export * from './Settings' export * from './OnboardingSettings' export * from './VoiceInputSettings' +export * from './VoiceInputDisplayMode' export * from './TranscriptionSettings' export * from './ShortcutsSettings' export * from './SystemSettings' diff --git a/src/features/voice-input/components/voice-input-container.tsx b/src/features/voice-input/components/voice-input-container.tsx index c46b762..b1a92d4 100644 --- a/src/features/voice-input/components/voice-input-container.tsx +++ b/src/features/voice-input/components/voice-input-container.tsx @@ -1,14 +1,23 @@ import { motion } from 'motion/react' +import { cn } from '@/lib/cn' + +import type { VoiceInputDisplayMode } from '@/features/settings/types/generated' import type { ReactNode } from 'react' interface VoiceInputContainerProps { children: ReactNode + mode?: VoiceInputDisplayMode } -export function VoiceInputContainer({ children }: VoiceInputContainerProps) { +export function VoiceInputContainer({ + children, + mode = 'standard', +}: VoiceInputContainerProps) { + const isMinimal = mode === 'minimal' + return ( -
+
-
+
{children}
diff --git a/src/features/voice-input/components/voice-input.tsx b/src/features/voice-input/components/voice-input.tsx index d181298..e3898e3 100644 --- a/src/features/voice-input/components/voice-input.tsx +++ b/src/features/voice-input/components/voice-input.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react' +import { load } from '@tauri-apps/plugin-store' +import { useEffect, useState } from 'react' import { LiveWaveform } from '@/components/ui/live-waveform' import { useAudioRecording } from '@/hooks/use-audio-recording' @@ -8,25 +9,75 @@ import { CancelButton } from './cancel-button' import { StopButton } from './stop-button' import { VoiceInputContainer } from './voice-input-container' +import type { VoiceInputDisplayMode } from '@/features/settings/types/generated' + +interface VoiceInputModePayload { + displayMode: VoiceInputDisplayMode +} + +interface StoredSettings { + voiceInput?: { + displayMode?: VoiceInputDisplayMode + } +} + export const VoiceInput = () => { const recording = useAudioRecording() const [audioLevel, setAudioLevel] = useState(0) + const [displayMode, setDisplayMode] = + useState('standard') + const [initialized, setInitialized] = useState(false) + + // Load initial display mode from settings on mount + useEffect(() => { + const loadInitialMode = async () => { + try { + const store = await load('settings') + const settings = await store.get('settings') + const mode = settings?.voiceInput?.displayMode ?? 'standard' + // Only accept valid modes (standard or minimal) + setDisplayMode(mode === 'minimal' ? 'minimal' : 'standard') + } catch (error) { + console.error('Failed to load display mode:', error) + } finally { + setInitialized(true) + } + } + void loadInitialMode() + }, []) const isTranscribing = recording.state === 'transcribing' const isProcessing = recording.state === 'stopping' || isTranscribing + // Only show buttons in standard mode + const showButtons = displayMode === 'standard' + useTauriEvent('audio-level', event => { setAudioLevel(event.payload) }) + // Listen for display mode from Rust backend + useTauriEvent('voice-input-mode', event => { + const mode = event.payload.displayMode + // Only accept valid modes (standard or minimal) + setDisplayMode(mode === 'minimal' ? 'minimal' : 'standard') + }) + + // Don't render until we have loaded the initial mode from settings + if (!initialized) { + return null + } + return ( - - + + {showButtons && ( + + )} -
+
{isProcessing ? ( ) : ( @@ -42,16 +93,18 @@ export const VoiceInput = () => { sensitivity={3.5} fadeEdges fadeWidth={10} - className="h-full w-full flex flex-1" + className="h-full w-full" /> )}
- + {showButtons && ( + + )} ) }