//! The tray card: a small frameless, always-on-top window that a left click on the tray icon shows next to the icon. //! //! It is created hidden at start (`create`) and only moved and shown on a click (`toggle`), so it opens instantly. The //! card never reads the stores: the main window pushes a snapshot of what it already knows (`card_snapshot`, kept here //! and forwarded as `card-snapshot`), and the card's buttons come back through `card_action`, which reuses the tray //! menu's paths (`tray-action` read / mute-toggle, `tray-route`), so Refresh is the main window's own Refresh all. use std::sync::Mutex; use std::time::{Duration, Instant}; use serde_json::Value; pub const LABEL: &str = "card"; /// Logical size of the card. pub const WIDTH: f64 = 360.0; pub const HEIGHT: f64 = 480.0; /// Gap between the card and the tray icon / screen edge (logical px). pub const GAP: f64 = 8.0; /// A snapshot larger than this is refused (the card shows a handful of rows). pub const MAX_SNAPSHOT: usize = 64 * 1024; /// A rectangle in physical pixels. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Rect { pub x: f64, pub y: f64, pub w: f64, pub h: f64, } impl Rect { fn cx(&self) -> f64 { self.x + self.w / 2.0 } fn cy(&self) -> f64 { self.y + self.h / 2.0 } } fn clamp(v: f64, lo: f64, hi: f64) -> f64 { if hi < lo { lo } else { v.clamp(lo, hi) } } /// Top-left corner of the card (physical px) for a tray icon at `tray`, inside `work` (the monitor's work area, without /// taskbar / menu bar / Dock). The card sits on the side of the icon that faces the screen: above a bottom taskbar, /// below the macOS menu bar, beside a left or right taskbar; centred on the icon along the bar, and always inside the /// work area. pub fn place(tray: Rect, work: Rect, card_w: f64, card_h: f64, gap: f64) -> (f64, f64) { let (cx, cy) = (tray.cx(), tray.cy()); let (right, bottom) = (work.x + work.w, work.y + work.h); let (x, y) = if cy >= bottom { (cx - card_w / 2.0, tray.y - card_h - gap) } else if cy <= work.y { (cx - card_w / 2.0, tray.y + tray.h + gap) } else if cx <= work.x { (tray.x + tray.w + gap, cy - card_h / 2.0) } else if cx >= right { (tray.x - card_w - gap, cy - card_h / 2.0) } else if cy > work.y + work.h / 2.0 { // icon inside the work area (auto-hidden taskbar, overflow flyout): open towards the middle (cx - card_w / 2.0, tray.y - card_h - gap) } else { (cx - card_w / 2.0, tray.y + tray.h + gap) }; (clamp(x, work.x + gap, right - card_w - gap).round(), clamp(y, work.y + gap, bottom - card_h - gap).round()) } /// Whether a card action word is one the card may send. pub fn valid_action(a: &str) -> bool { matches!(a, "hide" | "open" | "route" | "refresh" | "mute" | "quit" | "external" | "ready" | "size") } // ---- the last snapshot ---- static SNAPSHOT: Mutex> = Mutex::new(None); /// Keeps the main window's snapshot (an object, at most MAX_SNAPSHOT bytes). False when refused. pub fn set_snapshot(v: Value) -> bool { if !v.is_object() || v.to_string().len() > MAX_SNAPSHOT { return false; } if let Ok(mut s) = SNAPSHOT.lock() { *s = Some(v); } true } pub fn snapshot() -> Option { SNAPSHOT.lock().ok().and_then(|s| s.clone()) } // ---- show / hide ---- static HIDDEN_AT: Mutex> = Mutex::new(None); /// The card lost focus or was closed: remembered so that the click on the tray icon that caused the blur does not /// open it again at once (a second click closes it). pub fn note_hidden() { if let Ok(mut h) = HIDDEN_AT.lock() { *h = Some(Instant::now()); } } fn just_hidden() -> bool { HIDDEN_AT.lock().ok().and_then(|h| *h).is_some_and(|t| t.elapsed() < Duration::from_millis(300)) } #[cfg(desktop)] mod window { use super::*; use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewUrl, WebviewWindowBuilder}; /// The hidden card window, made once at start. pub fn create(app: &AppHandle) -> tauri::Result<()> { let b = WebviewWindowBuilder::new(app, LABEL, WebviewUrl::App("card.html".into())) .title("storebound") .inner_size(WIDTH, HEIGHT) .resizable(false) .decorations(false) .always_on_top(true) .skip_taskbar(true) .visible(false) .focused(false) .shadow(true); #[cfg(not(target_os = "macos"))] let b = b.transparent(true); b.build()?; Ok(()) } /// The icon rectangle of the last show, so a size change keeps the card anchored to the icon. static LAST_ICON: Mutex> = Mutex::new(None); /// Card height in CSS px (what the page measured), clamped; the window keeps WIDTH. fn height_px(h: f64) -> f64 { h.clamp(120.0, HEIGHT) } /// Puts the card next to `tray` at its current height: its edge GAP from the icon, inside the work area. fn anchor(app: &AppHandle, w: &tauri::WebviewWindow, tray: Rect, css_h: f64) { let monitor = app.monitor_from_point(tray.x + tray.w / 2.0, tray.y + tray.h / 2.0).ok().flatten().or_else(|| w.primary_monitor().ok().flatten()); if let Some(m) = monitor { let s = m.scale_factor(); let wa = m.work_area(); let work = Rect { x: wa.position.x as f64, y: wa.position.y as f64, w: wa.size.width as f64, h: wa.size.height as f64 }; let _ = w.set_size(tauri::PhysicalSize::new((WIDTH * s).round() as u32, (css_h * s).round() as u32)); let (x, y) = place(tray, work, WIDTH * s, css_h * s, GAP * s); let _ = w.set_position(PhysicalPosition::new(x as i32, y as i32)); } } /// The page measured its height: resize the window to it and keep it anchored (the bottom edge stays above a bottom /// taskbar, no empty band under the content). pub fn resize(app: &AppHandle, css_h: f64) { let Some(w) = app.get_webview_window(LABEL) else { return }; let h = height_px(css_h); if let Ok(mut c) = CURRENT_H.lock() { *c = h; } if let Some(tray) = LAST_ICON.lock().ok().and_then(|t| *t) { anchor(app, &w, tray, h); } } static CURRENT_H: Mutex = Mutex::new(HEIGHT); /// A left click on the tray icon: shows the card next to the icon, or hides it when it is open. pub fn toggle(app: &AppHandle, tray: Rect) { let Some(w) = app.get_webview_window(LABEL) else { return }; if w.is_visible().unwrap_or(false) { let _ = w.hide(); note_hidden(); return; } if just_hidden() { return; } if let Ok(mut t) = LAST_ICON.lock() { *t = Some(tray); } let h = CURRENT_H.lock().map(|c| *c).unwrap_or(HEIGHT); anchor(app, &w, tray, h); let _ = w.show(); let _ = w.set_focus(); let _ = app.emit_to(LABEL, "card-shown", ()); // the main window refreshes the parts the card shows that it keeps per app (reviews, health from the cache) let _ = app.emit_to(crate::policy::MAIN, "card-request", ()); } pub fn hide(app: &AppHandle) { if let Some(w) = app.get_webview_window(LABEL) { let _ = w.hide(); note_hidden(); } } } #[cfg(desktop)] pub use window::{create, hide, resize, toggle};