storebound/apps/desktop/src-tauri/src/card.rs

188 lines
7.4 KiB
Rust

//! 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<Option<Value>> = 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<Value> {
SNAPSHOT.lock().ok().and_then(|s| s.clone())
}
// ---- show / hide ----
static HIDDEN_AT: Mutex<Option<Instant>> = 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<R: Runtime>(app: &AppHandle<R>) -> 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<Option<Rect>> = 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<R: Runtime>(app: &AppHandle<R>, w: &tauri::WebviewWindow<R>, 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<R: Runtime>(app: &AppHandle<R>, 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<f64> = 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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: &AppHandle<R>) {
if let Some(w) = app.get_webview_window(LABEL) {
let _ = w.hide();
note_hidden();
}
}
}
#[cfg(desktop)]
pub use window::{create, hide, resize, toggle};