Handy/src-tauri/src/tray.rs
Fero 17277cf6ce
Save recordings before transcription (#1024)
* save recordings before transcription

* impart cj preferences

* cleanup

* re-transcribe anything

* format

* fix tests

* format

* ui cleanup

* format

* cleanup

* bunch of fixes, mainly for vm

* put comments back

* update transcription failure text

* alert -> toast

* spawn_blocking

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
2026-03-22 15:43:02 +08:00

294 lines
9.8 KiB
Rust

use crate::managers::history::{HistoryEntry, HistoryManager};
use crate::managers::model::ModelManager;
use crate::managers::transcription::TranscriptionManager;
use crate::settings;
use crate::tray_i18n::get_tray_translations;
use log::{error, info, warn};
use std::sync::Arc;
use tauri::image::Image;
use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::tray::TrayIcon;
use tauri::{AppHandle, Manager, Theme};
use tauri_plugin_clipboard_manager::ClipboardExt;
#[derive(Clone, Debug, PartialEq)]
pub enum TrayIconState {
Idle,
Recording,
Transcribing,
}
#[derive(Clone, Debug, PartialEq)]
pub enum AppTheme {
Dark,
Light,
Colored, // Pink/colored theme for Linux
}
/// Gets the current app theme, with Linux defaulting to Colored theme
pub fn get_current_theme(app: &AppHandle) -> AppTheme {
if cfg!(target_os = "linux") {
// On Linux, always use the colored theme
AppTheme::Colored
} else {
// On other platforms, map system theme to our app theme
if let Some(main_window) = app.get_webview_window("main") {
match main_window.theme().unwrap_or(Theme::Dark) {
Theme::Light => AppTheme::Light,
Theme::Dark => AppTheme::Dark,
_ => AppTheme::Dark, // Default fallback
}
} else {
AppTheme::Dark
}
}
}
/// Gets the appropriate icon path for the given theme and state
pub fn get_icon_path(theme: AppTheme, state: TrayIconState) -> &'static str {
match (theme, state) {
// Dark theme uses light icons
(AppTheme::Dark, TrayIconState::Idle) => "resources/tray_idle.png",
(AppTheme::Dark, TrayIconState::Recording) => "resources/tray_recording.png",
(AppTheme::Dark, TrayIconState::Transcribing) => "resources/tray_transcribing.png",
// Light theme uses dark icons
(AppTheme::Light, TrayIconState::Idle) => "resources/tray_idle_dark.png",
(AppTheme::Light, TrayIconState::Recording) => "resources/tray_recording_dark.png",
(AppTheme::Light, TrayIconState::Transcribing) => "resources/tray_transcribing_dark.png",
// Colored theme uses pink icons (for Linux)
(AppTheme::Colored, TrayIconState::Idle) => "resources/handy.png",
(AppTheme::Colored, TrayIconState::Recording) => "resources/recording.png",
(AppTheme::Colored, TrayIconState::Transcribing) => "resources/transcribing.png",
}
}
pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) {
let tray = app.state::<TrayIcon>();
let theme = get_current_theme(app);
let icon_path = get_icon_path(theme, icon.clone());
let _ = tray.set_icon(Some(
Image::from_path(
app.path()
.resolve(icon_path, tauri::path::BaseDirectory::Resource)
.expect("failed to resolve"),
)
.expect("failed to set icon"),
));
// Update menu based on state
update_tray_menu(app, &icon, None);
}
pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<&str>) {
let settings = settings::get_settings(app);
let locale = locale.unwrap_or(&settings.app_language);
let strings = get_tray_translations(Some(locale.to_string()));
// Platform-specific accelerators
#[cfg(target_os = "macos")]
let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q"));
#[cfg(not(target_os = "macos"))]
let (settings_accelerator, quit_accelerator) = (Some("Ctrl+,"), Some("Ctrl+Q"));
// Create common menu items
let version_label = if cfg!(debug_assertions) {
format!("Handy v{} (Dev)", env!("CARGO_PKG_VERSION"))
} else {
format!("Handy v{}", env!("CARGO_PKG_VERSION"))
};
let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>)
.expect("failed to create version item");
let settings_i = MenuItem::with_id(
app,
"settings",
&strings.settings,
true,
settings_accelerator,
)
.expect("failed to create settings item");
let check_updates_i = MenuItem::with_id(
app,
"check_updates",
&strings.check_updates,
settings.update_checks_enabled,
None::<&str>,
)
.expect("failed to create check updates item");
let copy_last_transcript_i = MenuItem::with_id(
app,
"copy_last_transcript",
&strings.copy_last_transcript,
true,
None::<&str>,
)
.expect("failed to create copy last transcript item");
let model_loaded = app.state::<Arc<TranscriptionManager>>().is_model_loaded();
let quit_i = MenuItem::with_id(app, "quit", &strings.quit, true, quit_accelerator)
.expect("failed to create quit item");
let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator");
// Build model submenu — label is the active model name
let model_manager = app.state::<Arc<ModelManager>>();
let models = model_manager.get_available_models();
let current_model_id = &settings.selected_model;
let mut downloaded: Vec<_> = models.into_iter().filter(|m| m.is_downloaded).collect();
downloaded.sort_by(|a, b| a.name.cmp(&b.name));
let submenu_label = downloaded
.iter()
.find(|m| m.id == *current_model_id)
.map(|m| m.name.clone())
.unwrap_or_else(|| strings.model.clone());
let model_submenu = {
let submenu = Submenu::with_id(app, "model_submenu", &submenu_label, true)
.expect("failed to create model submenu");
for model in &downloaded {
let is_active = model.id == *current_model_id;
let item_id = format!("model_select:{}", model.id);
let item =
CheckMenuItem::with_id(app, &item_id, &model.name, true, is_active, None::<&str>)
.expect("failed to create model item");
let _ = submenu.append(&item);
}
submenu
};
let unload_model_i = MenuItem::with_id(
app,
"unload_model",
&strings.unload_model,
model_loaded,
None::<&str>,
)
.expect("failed to create unload model item");
let menu = match state {
TrayIconState::Recording | TrayIconState::Transcribing => {
let cancel_i = MenuItem::with_id(app, "cancel", &strings.cancel, true, None::<&str>)
.expect("failed to create cancel item");
Menu::with_items(
app,
&[
&version_i,
&separator(),
&cancel_i,
&separator(),
&copy_last_transcript_i,
&separator(),
&settings_i,
&check_updates_i,
&separator(),
&quit_i,
],
)
.expect("failed to create menu")
}
TrayIconState::Idle => Menu::with_items(
app,
&[
&version_i,
&separator(),
&copy_last_transcript_i,
&separator(),
&model_submenu,
&unload_model_i,
&separator(),
&settings_i,
&check_updates_i,
&separator(),
&quit_i,
],
)
.expect("failed to create menu"),
};
let tray = app.state::<TrayIcon>();
let _ = tray.set_menu(Some(menu));
let _ = tray.set_icon_as_template(true);
}
fn last_transcript_text(entry: &HistoryEntry) -> &str {
entry
.post_processed_text
.as_deref()
.unwrap_or(&entry.transcription_text)
}
pub fn set_tray_visibility(app: &AppHandle, visible: bool) {
let tray = app.state::<TrayIcon>();
if let Err(e) = tray.set_visible(visible) {
error!("Failed to set tray visibility: {}", e);
} else {
info!("Tray visibility set to: {}", visible);
}
}
pub fn copy_last_transcript(app: &AppHandle) {
let history_manager = app.state::<Arc<HistoryManager>>();
let entry = match history_manager.get_latest_completed_entry() {
Ok(Some(entry)) => entry,
Ok(None) => {
warn!("No completed transcription history entries available for tray copy.");
return;
}
Err(err) => {
error!(
"Failed to fetch last completed transcription entry: {}",
err
);
return;
}
};
let text = last_transcript_text(&entry);
if text.trim().is_empty() {
warn!("Last completed transcription is empty; skipping tray copy.");
return;
}
if let Err(err) = app.clipboard().write_text(text) {
error!("Failed to copy last transcript to clipboard: {}", err);
return;
}
info!("Copied last transcript to clipboard via tray.");
}
#[cfg(test)]
mod tests {
use super::last_transcript_text;
use crate::managers::history::HistoryEntry;
fn build_entry(transcription: &str, post_processed: Option<&str>) -> HistoryEntry {
HistoryEntry {
id: 1,
file_name: "handy-1.wav".to_string(),
timestamp: 0,
saved: false,
title: "Recording".to_string(),
transcription_text: transcription.to_string(),
post_processed_text: post_processed.map(|text| text.to_string()),
post_process_prompt: None,
post_process_requested: false,
}
}
#[test]
fn uses_post_processed_text_when_available() {
let entry = build_entry("raw", Some("processed"));
assert_eq!(last_transcript_text(&entry), "processed");
}
#[test]
fn falls_back_to_raw_transcription() {
let entry = build_entry("raw", None);
assert_eq!(last_transcript_text(&entry), "raw");
}
}