188 lines
7.4 KiB
Rust
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};
|