278 lines
11 KiB
Rust
278 lines
11 KiB
Rust
//! The tray: tooltip, attention icon, context menu and the "where to go" memory of the last notification.
|
|
//!
|
|
//! The window pushes a small summary after every store read (`tray_update`); this module turns it into a menu model
|
|
//! (pure, tested in tests/tray.rs), rebuilds the menu, sets the tooltip and swaps the icon for one with a red dot while
|
|
//! something needs the owner. Menu clicks come back as events to the window: `tray-route` (a route as JSON) and
|
|
//! `tray-action` (`read`, `mute-toggle`).
|
|
//!
|
|
//! Clicking a desktop notification: the notification plugin has no click callback on Windows, macOS or Linux (its
|
|
//! actions are mobile only). So `notify` remembers where the notification points (`set_pending`) and `show_main`
|
|
//! hands it to the window as a `tray-route` the next time the window is shown from the tray or the Dock.
|
|
use std::sync::Mutex;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
use tauri::image::Image;
|
|
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
|
|
use tauri::{AppHandle, Runtime};
|
|
|
|
pub const ID_OPEN: &str = "tray-open";
|
|
pub const ID_READ: &str = "tray-read";
|
|
pub const ID_MUTE: &str = "tray-mute";
|
|
pub const ID_UPDATE: &str = "tray-update";
|
|
pub const ID_SETTINGS: &str = "tray-settings";
|
|
pub const ID_QUIT: &str = "tray-quit";
|
|
pub const ID_APP_PREFIX: &str = "tray-app:";
|
|
|
|
/// Apps listed in the submenu (more would not fit a menu anyway).
|
|
pub const MAX_APPS: usize = 30;
|
|
/// Windows cuts a tooltip at 127 UTF-16 units; stay under it everywhere.
|
|
pub const MAX_TIP: usize = 120;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
|
#[serde(rename_all = "camelCase", default)]
|
|
pub struct TrayApp {
|
|
/// The app's key in the window (opens it).
|
|
pub key: String,
|
|
pub name: String,
|
|
/// Plain words: "App Store: In review · Play: Live".
|
|
pub state: String,
|
|
}
|
|
|
|
/// The menu words in the window's language.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "camelCase", default)]
|
|
pub struct TrayLabels {
|
|
pub open: String,
|
|
pub read_now: String,
|
|
pub apps: String,
|
|
pub no_apps: String,
|
|
pub mute: String,
|
|
pub unmute: String,
|
|
pub update: String,
|
|
pub settings: String,
|
|
pub quit: String,
|
|
}
|
|
|
|
impl Default for TrayLabels {
|
|
fn default() -> TrayLabels {
|
|
TrayLabels {
|
|
open: "Open storebound".into(),
|
|
read_now: "Read the stores now".into(),
|
|
apps: "Apps".into(),
|
|
no_apps: "No apps yet".into(),
|
|
mute: "Mute notifications for 1 hour".into(),
|
|
unmute: "Unmute notifications".into(),
|
|
update: "Check for updates".into(),
|
|
settings: "Settings".into(),
|
|
quit: "Quit storebound".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
|
#[serde(rename_all = "camelCase", default)]
|
|
pub struct TraySummary {
|
|
pub tooltip: String,
|
|
/// Apps that need the owner (rejected, halted): above 0 the icon gets a red dot.
|
|
pub attention: u32,
|
|
/// Notifications are muted right now.
|
|
pub muted: bool,
|
|
pub apps: Vec<TrayApp>,
|
|
pub labels: TrayLabels,
|
|
}
|
|
|
|
/// One menu entry, before it is drawn.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Entry {
|
|
Item { id: String, label: String, enabled: bool },
|
|
Separator,
|
|
Sub { label: String, items: Vec<Entry> },
|
|
}
|
|
|
|
fn clean(s: &str, max: usize) -> String {
|
|
let one: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
|
let one = one.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if one.chars().count() <= max {
|
|
return one;
|
|
}
|
|
let mut cut: String = one.chars().take(max.saturating_sub(1)).collect();
|
|
cut.push('…');
|
|
cut
|
|
}
|
|
|
|
/// The tooltip: the window's text, one line, short; "storebound" when it sent none.
|
|
pub fn tooltip_text(s: &TraySummary) -> String {
|
|
let t = clean(&s.tooltip, MAX_TIP);
|
|
if t.is_empty() { "storebound".into() } else { t }
|
|
}
|
|
|
|
/// The context menu: Open, Read now, Apps (one entry per app with its state), Mute / Unmute, Check for updates,
|
|
/// Settings, Quit.
|
|
pub fn menu_model(s: &TraySummary) -> Vec<Entry> {
|
|
let item = |id: &str, label: &str| Entry::Item { id: id.into(), label: clean(label, 90), enabled: true };
|
|
let l = &s.labels;
|
|
let apps: Vec<Entry> = if s.apps.is_empty() {
|
|
vec![Entry::Item { id: "tray-none".into(), label: clean(&l.no_apps, 90), enabled: false }]
|
|
} else {
|
|
s.apps
|
|
.iter()
|
|
.filter(|a| !a.key.is_empty() && !a.key.chars().any(char::is_control))
|
|
.take(MAX_APPS)
|
|
.map(|a| {
|
|
let name = clean(&a.name, 40);
|
|
let state = clean(&a.state, 60);
|
|
let label = if state.is_empty() { name } else { format!("{name} · {state}") };
|
|
Entry::Item { id: format!("{ID_APP_PREFIX}{}", a.key.chars().take(200).collect::<String>()), label, enabled: true }
|
|
})
|
|
.collect()
|
|
};
|
|
vec![
|
|
item(ID_OPEN, &l.open),
|
|
item(ID_READ, &l.read_now),
|
|
Entry::Separator,
|
|
Entry::Sub { label: clean(&l.apps, 40), items: apps },
|
|
Entry::Separator,
|
|
item(ID_MUTE, if s.muted { &l.unmute } else { &l.mute }),
|
|
item(ID_UPDATE, &l.update),
|
|
item(ID_SETTINGS, &l.settings),
|
|
Entry::Separator,
|
|
item(ID_QUIT, &l.quit),
|
|
]
|
|
}
|
|
|
|
/// What a menu click does.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Action {
|
|
/// Show the window.
|
|
Open,
|
|
Quit,
|
|
/// Tell the window (no window needed): `tray-action` with this word.
|
|
Event(&'static str),
|
|
/// Show the window on this route (JSON for `tray-route`).
|
|
Route(String),
|
|
}
|
|
|
|
pub fn action_of(id: &str) -> Option<Action> {
|
|
match id {
|
|
ID_OPEN => Some(Action::Open),
|
|
ID_READ => Some(Action::Event("read")),
|
|
ID_MUTE => Some(Action::Event("mute-toggle")),
|
|
ID_UPDATE => Some(Action::Route(serde_json::json!({ "screen": "settings", "section": "updates" }).to_string())),
|
|
ID_SETTINGS => Some(Action::Route(serde_json::json!({ "screen": "settings" }).to_string())),
|
|
ID_QUIT => Some(Action::Quit),
|
|
_ => id.strip_prefix(ID_APP_PREFIX).filter(|k| !k.is_empty()).map(|k| Action::Route(serde_json::json!({ "screen": "app", "appId": k, "tab": "releases" }).to_string())),
|
|
}
|
|
}
|
|
|
|
/// A notification target the window may be sent to later: a small JSON object of strings with a known `screen`.
|
|
pub fn valid_route(json: &str) -> bool {
|
|
if json.len() > 400 {
|
|
return false;
|
|
}
|
|
let Ok(Value::Object(o)) = serde_json::from_str::<Value>(json) else { return false };
|
|
let screens = ["portfolio", "app", "apps", "accounts", "ignored", "dependencies", "settings"];
|
|
o.len() <= 4 && o.values().all(|v| v.as_str().is_some_and(|s| s.len() <= 300 && !s.chars().any(char::is_control))) && o.get("screen").and_then(Value::as_str).is_some_and(|s| screens.contains(&s))
|
|
}
|
|
|
|
/// The default icon with a red dot (white ring) in the top right corner, for "something needs you". RGBA in, RGBA out.
|
|
pub fn with_attention_dot(rgba: &[u8], w: u32, h: u32) -> Vec<u8> {
|
|
let mut out = rgba.to_vec();
|
|
if w == 0 || h == 0 || out.len() != (w as usize) * (h as usize) * 4 {
|
|
return out;
|
|
}
|
|
let side = w.min(h) as f32;
|
|
let r = side * 0.27;
|
|
let (cx, cy) = (w as f32 - r - side * 0.02, r + side * 0.02);
|
|
let blend = |px: &mut [u8], color: [f32; 3], c: f32| {
|
|
if c <= 0.0 {
|
|
return;
|
|
}
|
|
let a = px[3] as f32 / 255.0;
|
|
let oa = a + (1.0 - a) * c;
|
|
for i in 0..3 {
|
|
// the dot covers what is under it by `c`; the alpha of the icon only grows
|
|
px[i] = (px[i] as f32 * (1.0 - c) + color[i] * c).round().clamp(0.0, 255.0) as u8;
|
|
}
|
|
px[3] = (oa * 255.0).round().clamp(0.0, 255.0) as u8;
|
|
};
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt();
|
|
let i = ((y * w + x) * 4) as usize;
|
|
let px = &mut out[i..i + 4];
|
|
blend(px, [255.0, 255.0, 255.0], (r + 0.5 - d).clamp(0.0, 1.0));
|
|
blend(px, [229.0, 57.0, 53.0], (r * 0.78 + 0.5 - d).clamp(0.0, 1.0));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// ---- where the last notification pointed ----
|
|
|
|
static PENDING: Mutex<Option<(String, Instant)>> = Mutex::new(None);
|
|
const PENDING_FOR: Duration = Duration::from_secs(2 * 3600);
|
|
|
|
/// Remembers the route (JSON) of the notification just shown. Ignores anything `valid_route` refuses.
|
|
pub fn set_pending(route: &str) {
|
|
if valid_route(route) {
|
|
if let Ok(mut p) = PENDING.lock() {
|
|
*p = Some((route.to_string(), Instant::now()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The remembered route, once (it is forgotten after it is handed out, and after two hours).
|
|
pub fn take_pending() -> Option<String> {
|
|
let (route, at) = PENDING.lock().ok()?.take()?;
|
|
(at.elapsed() < PENDING_FOR).then_some(route)
|
|
}
|
|
|
|
// ---- drawing ----
|
|
|
|
static LAST: Mutex<Option<String>> = Mutex::new(None);
|
|
|
|
fn build_menu<R: Runtime>(app: &AppHandle<R>, model: &[Entry]) -> tauri::Result<Menu<R>> {
|
|
let menu = Menu::new(app)?;
|
|
for e in model {
|
|
match e {
|
|
Entry::Item { id, label, enabled } => menu.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?,
|
|
Entry::Separator => menu.append(&PredefinedMenuItem::separator(app)?)?,
|
|
Entry::Sub { label, items } => {
|
|
let sub = Submenu::new(app, label, true)?;
|
|
for it in items {
|
|
if let Entry::Item { id, label, enabled } = it {
|
|
sub.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?;
|
|
}
|
|
}
|
|
menu.append(&sub)?;
|
|
}
|
|
}
|
|
}
|
|
Ok(menu)
|
|
}
|
|
|
|
/// The menu shown before the window has sent anything (English words).
|
|
pub fn initial_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
|
|
build_menu(app, &menu_model(&TraySummary::default()))
|
|
}
|
|
|
|
/// Applies a summary: tooltip, menu and icon of the tray with id `main`. Skips the work when nothing changed.
|
|
pub fn apply<R: Runtime>(app: &AppHandle<R>, s: &TraySummary) -> tauri::Result<()> {
|
|
let Some(tray) = app.tray_by_id("main") else { return Ok(()) };
|
|
let key = format!("{s:?}");
|
|
if LAST.lock().ok().is_some_and(|l| l.as_deref() == Some(key.as_str())) {
|
|
return Ok(());
|
|
}
|
|
tray.set_tooltip(Some(tooltip_text(s)))?;
|
|
tray.set_menu(Some(build_menu(app, &menu_model(s))?))?;
|
|
if let Some(icon) = app.default_window_icon() {
|
|
let img = if s.attention > 0 { Image::new_owned(with_attention_dot(icon.rgba(), icon.width(), icon.height()), icon.width(), icon.height()) } else { icon.clone() };
|
|
tray.set_icon(Some(img))?;
|
|
}
|
|
if let Ok(mut l) = LAST.lock() {
|
|
*l = Some(key);
|
|
}
|
|
Ok(())
|
|
}
|