windows.rs
Language Rust8.5 KB234 lines
//! The two windows, the tray and the global shortcut.//!//! On macOS both windows sit on native vibrancy (the frosted material behind//! the glass panels). Elsewhere the window is opaque and the page paints its//! own backdrop.use std::time::{Duration, Instant};use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};use tauri::tray::TrayIconBuilder;use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};use tauri_plugin_global_shortcut::GlobalShortcutExt;use crate::commands::{load_settings, spend};use crate::state::{lock, AppState};pub const QUICK_WIDTH: f64 = 640.0;/// The quick window's height with no answer, and the most it grows to. The/// interface has the same two numbers in `src/lib/theme.ts`.pub const QUICK_HEIGHT: f64 = 76.0;pub const QUICK_MAX: f64 = 560.0;/// Losing focus this soon after the quick window appears is the show itself, not a click away.const BLUR_GRACE: Duration = Duration::from_millis(150);pub fn create_windows(app: &AppHandle) -> tauri::Result<()> {let main = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())).title("Cortex").inner_size(1100.0, 720.0).min_inner_size(720.0, 480.0).visible(false); // shown by window_ready once the page has painted#[cfg(target_os = "macos")]let main = {use tauri::window::{Effect, EffectState, EffectsBuilder};main.transparent(true).title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true).effects(EffectsBuilder::new().effect(Effect::Sidebar).state(EffectState::FollowsWindowActiveState).build())};let main = main.build()?;let handle = app.clone();main.on_window_event(move |ev| {if let WindowEvent::CloseRequested { api, .. } = ev {// Closing hides; Quit (menu, tray, Cmd+Q) quits.api.prevent_close();if let Some(w) = handle.get_webview_window("main") {let _ = w.hide();}}});let quick = WebviewWindowBuilder::new(app, "quick", WebviewUrl::App("quick.html".into())).title("Cortex quick prompt").inner_size(QUICK_WIDTH, QUICK_HEIGHT).decorations(false).resizable(false).always_on_top(true).skip_taskbar(true).visible_on_all_workspaces(true).visible(false);#[cfg(target_os = "macos")]let quick = {use tauri::window::{Effect, EffectState, EffectsBuilder};quick.transparent(true).effects(EffectsBuilder::new().effect(Effect::HudWindow).state(EffectState::Active).radius(14.0).build())};let quick = quick.build()?;let handle = app.clone();quick.on_window_event(move |ev| {if let WindowEvent::Focused(false) = ev {let state = handle.state::<AppState>();let recent = lock(&state.quick_shown_at).is_some_and(|t| t.elapsed() < BLUR_GRACE);let dialog = *lock(&state.dialog_open);if !recent && !dialog {hide_quick(&handle);}}});Ok(())}pub fn show_main(app: &AppHandle) {#[cfg(target_os = "macos")]let _ = app.show();if let Some(w) = app.get_webview_window("main") {let _ = w.unminimize();let _ = w.show();let _ = w.set_focus();}}pub fn toggle_quick(app: &AppHandle) {let Some(w) = app.get_webview_window("quick") else { return };if w.is_visible().unwrap_or(false) {hide_quick(app);return;}#[cfg(target_os = "macos")]let _ = app.show();*lock(&app.state::<AppState>().quick_shown_at) = Some(Instant::now());let _ = w.set_size(tauri::LogicalSize::new(QUICK_WIDTH, QUICK_HEIGHT));// Center on the monitor under the cursor, a third of the way down.if let (Ok(pos), Ok(monitors)) = (app.cursor_position(), app.available_monitors()) {if let Some(m) = monitors.iter().find(|m| {let (p, s) = (m.position(), m.size());pos.x >= p.x as f64&& pos.x < (p.x + s.width as i32) as f64&& pos.y >= p.y as f64&& pos.y < (p.y + s.height as i32) as f64}) {let scale = m.scale_factor();let (p, s) = (m.position(), m.size());let x = p.x as f64 + (s.width as f64 - QUICK_WIDTH * scale) / 2.0;let y = p.y as f64 + s.height as f64 / 3.0;let _ = w.set_position(tauri::PhysicalPosition::new(x, y));}}let _ = w.show();let _ = w.set_focus();let _ = w.emit("quick-shown", ());}pub fn hide_quick(app: &AppHandle) {if let Some(w) = app.get_webview_window("quick") {let _ = w.hide();}}/// Swap the global shortcut. On failure the old one is put back.pub fn set_shortcut(app: &AppHandle, old: &str, new: &str) -> Result<(), String> {let gs = app.global_shortcut();let _ = gs.unregister(old);match gs.register(new) {Ok(()) => Ok(()),Err(err) => {let _ = gs.register(old);Err(format!("Could not use {new}: {err}"))}}}/// Set the main window's macOS material. The blur strength is fixed by macOS/// for each material; they differ in how much tint and light they let through.pub fn apply_material(app: &AppHandle, material: &str) {#[cfg(target_os = "macos")]{use tauri::window::{Effect, EffectState, EffectsBuilder};let effect = match material {"window" => Effect::UnderWindowBackground,"fullscreen" => Effect::FullScreenUI,"hud" => Effect::HudWindow,_ => Effect::Sidebar,};if let Some(w) = app.get_webview_window("main") {let _ = w.set_effects(EffectsBuilder::new().effect(effect).state(EffectState::FollowsWindowActiveState).build());}}#[cfg(not(target_os = "macos"))]let _ = (app, material);}pub fn apply_theme(app: &AppHandle, theme: &str) {let t = match theme {"light" => Some(tauri::Theme::Light),"dark" => Some(tauri::Theme::Dark),_ => None,};for label in ["main", "quick"] {if let Some(w) = app.get_webview_window(label) {let _ = w.set_theme(t);}}}fn spend_label(app: &AppHandle) -> String {match spend(&app.state::<AppState>()) {Ok(s) => {let mut label = format!("This month: {}", cortex_core::cost::format_usd(s.month_nanousd));if let Some(b) = s.budget_usd {label.push_str(&format!(" of ${b:.2}"));}label}Err(_) => "This month: unavailable".into(),}}pub fn refresh_tray_spend(app: &AppHandle) {let label = spend_label(app);if let Some(item) = lock(&app.state::<AppState>().tray_spend).as_ref() {let _ = item.set_text(label);}}pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {let new_chat = MenuItem::with_id(app, "new", "New chat", true, None::<&str>)?;let quick = MenuItem::with_id(app, "quick", "Quick prompt", true, None::<&str>)?;let spend_item = MenuItem::with_id(app, "spend", spend_label(app), false, None::<&str>)?;let settings = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?;let quit = MenuItem::with_id(app, "quit", "Quit Cortex", true, None::<&str>)?;let sep = || PredefinedMenuItem::separator(app);let menu = Menu::with_items(app, &[&new_chat, &quick, &sep()?, &spend_item, &sep()?, &settings, &quit])?;let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;TrayIconBuilder::with_id("cortex").icon(icon).icon_as_template(true).tooltip("Cortex").menu(&menu).show_menu_on_left_click(true).on_menu_event(|app, event| match event.id.as_ref() {"new" => {show_main(app);let _ = app.emit_to("main", "new-chat", ());}"quick" => toggle_quick(app),"settings" => {show_main(app);let _ = app.emit_to("main", "open-settings", ());}"quit" => app.exit(0),_ => {}}).build(app)?;*lock(&app.state::<AppState>().tray_spend) = Some(spend_item);Ok(())}pub fn register_initial_shortcut(app: &AppHandle) {let shortcut = load_settings(&app.state::<AppState>()).shortcut;if let Err(err) = app.global_shortcut().register(shortcut.as_str()) {eprintln!("cortex: shortcut {shortcut} unavailable: {err}");}}