mattrguerra/cortexmainPrivate on GitHub, published here in full

windows.rs

Language Rust8.5 KB234 lines

src-tauri/src/windows.rs

  1. //! The two windows, the tray and the global shortcut.
  2. //!
  3. //! On macOS both windows sit on native vibrancy (the frosted material behind
  4. //! the glass panels). Elsewhere the window is opaque and the page paints its
  5. //! own backdrop.
  6. use std::time::{Duration, Instant};
  7. use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
  8. use tauri::tray::TrayIconBuilder;
  9. use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
  10. use tauri_plugin_global_shortcut::GlobalShortcutExt;
  11. use crate::commands::{load_settings, spend};
  12. use crate::state::{lock, AppState};
  13. pub const QUICK_WIDTH: f64 = 640.0;
  14. /// The quick window's height with no answer, and the most it grows to. The
  15. /// interface has the same two numbers in `src/lib/theme.ts`.
  16. pub const QUICK_HEIGHT: f64 = 76.0;
  17. pub const QUICK_MAX: f64 = 560.0;
  18. /// Losing focus this soon after the quick window appears is the show itself, not a click away.
  19. const BLUR_GRACE: Duration = Duration::from_millis(150);
  20. pub fn create_windows(app: &AppHandle) -> tauri::Result<()> {
  21. let main = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
  22. .title("Cortex")
  23. .inner_size(1100.0, 720.0)
  24. .min_inner_size(720.0, 480.0)
  25. .visible(false); // shown by window_ready once the page has painted
  26. #[cfg(target_os = "macos")]
  27. let main = {
  28. use tauri::window::{Effect, EffectState, EffectsBuilder};
  29. main.transparent(true)
  30. .title_bar_style(tauri::TitleBarStyle::Overlay)
  31. .hidden_title(true)
  32. .effects(EffectsBuilder::new().effect(Effect::Sidebar).state(EffectState::FollowsWindowActiveState).build())
  33. };
  34. let main = main.build()?;
  35. let handle = app.clone();
  36. main.on_window_event(move |ev| {
  37. if let WindowEvent::CloseRequested { api, .. } = ev {
  38. // Closing hides; Quit (menu, tray, Cmd+Q) quits.
  39. api.prevent_close();
  40. if let Some(w) = handle.get_webview_window("main") {
  41. let _ = w.hide();
  42. }
  43. }
  44. });
  45. let quick = WebviewWindowBuilder::new(app, "quick", WebviewUrl::App("quick.html".into()))
  46. .title("Cortex quick prompt")
  47. .inner_size(QUICK_WIDTH, QUICK_HEIGHT)
  48. .decorations(false)
  49. .resizable(false)
  50. .always_on_top(true)
  51. .skip_taskbar(true)
  52. .visible_on_all_workspaces(true)
  53. .visible(false);
  54. #[cfg(target_os = "macos")]
  55. let quick = {
  56. use tauri::window::{Effect, EffectState, EffectsBuilder};
  57. quick
  58. .transparent(true)
  59. .effects(EffectsBuilder::new().effect(Effect::HudWindow).state(EffectState::Active).radius(14.0).build())
  60. };
  61. let quick = quick.build()?;
  62. let handle = app.clone();
  63. quick.on_window_event(move |ev| {
  64. if let WindowEvent::Focused(false) = ev {
  65. let state = handle.state::<AppState>();
  66. let recent = lock(&state.quick_shown_at).is_some_and(|t| t.elapsed() < BLUR_GRACE);
  67. let dialog = *lock(&state.dialog_open);
  68. if !recent && !dialog {
  69. hide_quick(&handle);
  70. }
  71. }
  72. });
  73. Ok(())
  74. }
  75. pub fn show_main(app: &AppHandle) {
  76. #[cfg(target_os = "macos")]
  77. let _ = app.show();
  78. if let Some(w) = app.get_webview_window("main") {
  79. let _ = w.unminimize();
  80. let _ = w.show();
  81. let _ = w.set_focus();
  82. }
  83. }
  84. pub fn toggle_quick(app: &AppHandle) {
  85. let Some(w) = app.get_webview_window("quick") else { return };
  86. if w.is_visible().unwrap_or(false) {
  87. hide_quick(app);
  88. return;
  89. }
  90. #[cfg(target_os = "macos")]
  91. let _ = app.show();
  92. *lock(&app.state::<AppState>().quick_shown_at) = Some(Instant::now());
  93. let _ = w.set_size(tauri::LogicalSize::new(QUICK_WIDTH, QUICK_HEIGHT));
  94. // Center on the monitor under the cursor, a third of the way down.
  95. if let (Ok(pos), Ok(monitors)) = (app.cursor_position(), app.available_monitors()) {
  96. if let Some(m) = monitors.iter().find(|m| {
  97. let (p, s) = (m.position(), m.size());
  98. pos.x >= p.x as f64
  99. && pos.x < (p.x + s.width as i32) as f64
  100. && pos.y >= p.y as f64
  101. && pos.y < (p.y + s.height as i32) as f64
  102. }) {
  103. let scale = m.scale_factor();
  104. let (p, s) = (m.position(), m.size());
  105. let x = p.x as f64 + (s.width as f64 - QUICK_WIDTH * scale) / 2.0;
  106. let y = p.y as f64 + s.height as f64 / 3.0;
  107. let _ = w.set_position(tauri::PhysicalPosition::new(x, y));
  108. }
  109. }
  110. let _ = w.show();
  111. let _ = w.set_focus();
  112. let _ = w.emit("quick-shown", ());
  113. }
  114. pub fn hide_quick(app: &AppHandle) {
  115. if let Some(w) = app.get_webview_window("quick") {
  116. let _ = w.hide();
  117. }
  118. }
  119. /// Swap the global shortcut. On failure the old one is put back.
  120. pub fn set_shortcut(app: &AppHandle, old: &str, new: &str) -> Result<(), String> {
  121. let gs = app.global_shortcut();
  122. let _ = gs.unregister(old);
  123. match gs.register(new) {
  124. Ok(()) => Ok(()),
  125. Err(err) => {
  126. let _ = gs.register(old);
  127. Err(format!("Could not use {new}: {err}"))
  128. }
  129. }
  130. }
  131. /// Set the main window's macOS material. The blur strength is fixed by macOS
  132. /// for each material; they differ in how much tint and light they let through.
  133. pub fn apply_material(app: &AppHandle, material: &str) {
  134. #[cfg(target_os = "macos")]
  135. {
  136. use tauri::window::{Effect, EffectState, EffectsBuilder};
  137. let effect = match material {
  138. "window" => Effect::UnderWindowBackground,
  139. "fullscreen" => Effect::FullScreenUI,
  140. "hud" => Effect::HudWindow,
  141. _ => Effect::Sidebar,
  142. };
  143. if let Some(w) = app.get_webview_window("main") {
  144. let _ = w
  145. .set_effects(EffectsBuilder::new().effect(effect).state(EffectState::FollowsWindowActiveState).build());
  146. }
  147. }
  148. #[cfg(not(target_os = "macos"))]
  149. let _ = (app, material);
  150. }
  151. pub fn apply_theme(app: &AppHandle, theme: &str) {
  152. let t = match theme {
  153. "light" => Some(tauri::Theme::Light),
  154. "dark" => Some(tauri::Theme::Dark),
  155. _ => None,
  156. };
  157. for label in ["main", "quick"] {
  158. if let Some(w) = app.get_webview_window(label) {
  159. let _ = w.set_theme(t);
  160. }
  161. }
  162. }
  163. fn spend_label(app: &AppHandle) -> String {
  164. match spend(&app.state::<AppState>()) {
  165. Ok(s) => {
  166. let mut label = format!("This month: {}", cortex_core::cost::format_usd(s.month_nanousd));
  167. if let Some(b) = s.budget_usd {
  168. label.push_str(&format!(" of ${b:.2}"));
  169. }
  170. label
  171. }
  172. Err(_) => "This month: unavailable".into(),
  173. }
  174. }
  175. pub fn refresh_tray_spend(app: &AppHandle) {
  176. let label = spend_label(app);
  177. if let Some(item) = lock(&app.state::<AppState>().tray_spend).as_ref() {
  178. let _ = item.set_text(label);
  179. }
  180. }
  181. pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
  182. let new_chat = MenuItem::with_id(app, "new", "New chat", true, None::<&str>)?;
  183. let quick = MenuItem::with_id(app, "quick", "Quick prompt", true, None::<&str>)?;
  184. let spend_item = MenuItem::with_id(app, "spend", spend_label(app), false, None::<&str>)?;
  185. let settings = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?;
  186. let quit = MenuItem::with_id(app, "quit", "Quit Cortex", true, None::<&str>)?;
  187. let sep = || PredefinedMenuItem::separator(app);
  188. let menu = Menu::with_items(app, &[&new_chat, &quick, &sep()?, &spend_item, &sep()?, &settings, &quit])?;
  189. let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
  190. TrayIconBuilder::with_id("cortex")
  191. .icon(icon)
  192. .icon_as_template(true)
  193. .tooltip("Cortex")
  194. .menu(&menu)
  195. .show_menu_on_left_click(true)
  196. .on_menu_event(|app, event| match event.id.as_ref() {
  197. "new" => {
  198. show_main(app);
  199. let _ = app.emit_to("main", "new-chat", ());
  200. }
  201. "quick" => toggle_quick(app),
  202. "settings" => {
  203. show_main(app);
  204. let _ = app.emit_to("main", "open-settings", ());
  205. }
  206. "quit" => app.exit(0),
  207. _ => {}
  208. })
  209. .build(app)?;
  210. *lock(&app.state::<AppState>().tray_spend) = Some(spend_item);
  211. Ok(())
  212. }
  213. pub fn register_initial_shortcut(app: &AppHandle) {
  214. let shortcut = load_settings(&app.state::<AppState>()).shortcut;
  215. if let Err(err) = app.global_shortcut().register(shortcut.as_str()) {
  216. eprintln!("cortex: shortcut {shortcut} unavailable: {err}");
  217. }
  218. }