* 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>
294 lines
9.8 KiB
Rust
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(),
|
|
©_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(),
|
|
©_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");
|
|
}
|
|
}
|