diff --git a/README.md b/README.md index 3807775..058ac7c 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ displays, refreshed every 5 minutes. - **⚙** — Settings (custom claude command, refresh interval, autostart, distro override). - **×** — quit. - The window is **resizable** — drag any edge. +- **System tray** — there's a tray icon next to the Windows clock while the + widget is running. Left-click to bring the window back if you lose it + off-screen; right-click for Show / Hide / Refresh / Quit. ## Troubleshooting diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cc4f729..2a3a424 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-autostart = "2" tauri-plugin-store = "2" tauri-plugin-dialog = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c494375..7a45514 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,8 +13,24 @@ mod usage; mod watch; use tauri::{Emitter, Manager}; +use tauri::menu::{MenuBuilder, MenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use std::time::Duration; +fn show_main_window(app: &tauri::AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } +} + +fn hide_main_window(app: &tauri::AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.hide(); + } +} + async fn refresh_cli_loop(app: tauri::AppHandle, state: state::SharedState) { // Small initial delay so we don't compete with cold-start file scanning. tokio::time::sleep(Duration::from_secs(2)).await; @@ -66,6 +82,61 @@ pub fn run() { let shared = state::AppState::new(settings); app.manage(shared.clone()); + // Tray icon: always-visible recovery handle in case the window + // gets lost (off-screen, hidden by another app, etc.). + let show_item = MenuItem::with_id(app, "tray-show", "Show", true, None::<&str>)?; + let hide_item = MenuItem::with_id(app, "tray-hide", "Hide", true, None::<&str>)?; + let refresh_item = MenuItem::with_id(app, "tray-refresh", "Refresh /usage", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "tray-quit", "Quit", true, None::<&str>)?; + + let menu = MenuBuilder::new(app) + .item(&show_item) + .item(&hide_item) + .separator() + .item(&refresh_item) + .separator() + .item(&quit_item) + .build()?; + + let state_for_tray = shared.clone(); + let _tray = TrayIconBuilder::with_id("main-tray") + .icon(app.default_window_icon().unwrap().clone()) + .tooltip("Claude Usage Widget") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(move |app, event| match event.id().as_ref() { + "tray-show" => show_main_window(app), + "tray-hide" => hide_main_window(app), + "tray-refresh" => { + let app = app.clone(); + let state = state_for_tray.clone(); + tauri::async_runtime::spawn(async move { + let cmd = state.settings.read().claude_command.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + cli_usage::fetch_blocking(cmd.as_deref()) + }) + .await; + if let Ok(Ok(u)) = result { + *state.cli_usage.write() = Some(u.clone()); + let _ = app.emit("cli-usage-updated", &u); + } + }); + } + "tray-quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + show_main_window(tray.app_handle()); + } + }) + .build(app)?; + let handle = app.handle().clone(); let state_for_task = shared.clone(); tauri::async_runtime::spawn(async move {