749 lines
30 KiB
Rust
749 lines
30 KiB
Rust
//! The tray's right-click menu, drawn by the app itself instead of the OS menu (tray-menu.html, src/traymenu/).
|
|
//!
|
|
//! - One tree: tray.rs builds the menu as `Entry`s, turned here into `Node`s. Every submenu has a stable key (its label
|
|
//! without the live part), so an open or pinned panel finds its submenu again after the values change.
|
|
//! - One window per panel: the root menu and each submenu are their own frameless, always-on-top `menu-N` windows. The
|
|
//! page measures itself and calls `menu_ready`; Rust sizes, places (inside the monitor's work area) and shows it.
|
|
//! - The panel book (`Book`, pure, tests/menu_panel.rs) records which panels are open, which are pinned and whose child
|
|
//! each one is. Transient panels close together (focus left all of them, Esc, a pick); pinned ones stay, can be
|
|
//! dragged, step aside so they never cover each other and come back after a restart (`menuPins` in state.json).
|
|
//! - Live: every `tray_update` pushes the tree to the open panels (`menu-model`), which update in place. Nothing polls.
|
|
//! - Idle: a closed panel is destroyed, never hidden.
|
|
//! - Fallback: when a panel window cannot be built or never reports ready, the native tray menu (rendered from the same
|
|
//! tree) comes back. Linux always uses it (its tray sends no click events).
|
|
//!
|
|
//! The top half is pure and covered by tests/menu_panel.rs.
|
|
use std::collections::BTreeMap;
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::tray::Entry;
|
|
|
|
pub const LABEL_PREFIX: &str = "menu-";
|
|
pub const PAGE: &str = "tray-menu.html";
|
|
pub const MODEL_EVENT: &str = "menu-model";
|
|
pub const PANEL_EVENT: &str = "menu-panel";
|
|
/// Gap to the work area's edges (physical px at scale 1).
|
|
pub const GAP: f64 = 8.0;
|
|
/// The estimated size of a panel before it measured itself (logical px).
|
|
pub const EST: (f64, f64) = (280.0, 220.0);
|
|
|
|
// ---------------------------------------------------------------- model
|
|
|
|
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
|
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
|
pub enum Node {
|
|
Item { id: String, label: String, enabled: bool },
|
|
Sep,
|
|
Sub { key: String, label: String, items: Vec<Node> },
|
|
}
|
|
|
|
/// A submenu's stable key: its label without status glyphs and without the live part after ':' / '·' / '('
|
|
/// ("Apps (3)" -> "apps", "Apps · 2 need you" -> "apps"). Pinned panels are found again by these keys.
|
|
pub fn sub_key(label: &str) -> String {
|
|
let head = label.split([':', '\u{00B7}', '(']).next().unwrap_or("");
|
|
let mut out = String::new();
|
|
for c in head.chars() {
|
|
if c.is_alphanumeric() {
|
|
out.extend(c.to_lowercase());
|
|
} else if (c == ' ' || c == '-' || c == '_') && !out.is_empty() && !out.ends_with('-') {
|
|
out.push('-');
|
|
}
|
|
}
|
|
let out = out.trim_end_matches('-').to_string();
|
|
if out.is_empty() { "sub".into() } else { out }
|
|
}
|
|
|
|
/// Sibling submenus with the same key get "-2", "-3"...
|
|
pub fn unique_keys(mut nodes: Vec<Node>) -> Vec<Node> {
|
|
let mut seen: BTreeMap<String, u32> = BTreeMap::new();
|
|
for n in nodes.iter_mut() {
|
|
if let Node::Sub { key, .. } = n {
|
|
let count = seen.entry(key.clone()).or_insert(0);
|
|
*count += 1;
|
|
if *count > 1 {
|
|
*key = format!("{key}-{count}");
|
|
}
|
|
}
|
|
}
|
|
nodes
|
|
}
|
|
|
|
/// The tray menu entries as panel nodes. A submenu keeps the fixed key tray.rs gives it (so a translated label never
|
|
/// changes it), else the key comes from its label.
|
|
pub fn from_entries(entries: &[Entry]) -> Vec<Node> {
|
|
let nodes = entries
|
|
.iter()
|
|
.map(|e| match e {
|
|
Entry::Item { id, label, enabled } => Node::Item { id: id.clone(), label: label.clone(), enabled: *enabled },
|
|
Entry::Separator => Node::Sep,
|
|
Entry::Sub { key, label, items } => Node::Sub { key: if key.is_empty() { sub_key(label) } else { sub_key(key) }, label: label.clone(), items: from_entries(items) },
|
|
})
|
|
.collect();
|
|
unique_keys(nodes)
|
|
}
|
|
|
|
/// The items of the submenu at `path` (empty path: the root) and its label.
|
|
pub fn find<'a>(nodes: &'a [Node], path: &[String]) -> Option<(&'a [Node], Option<&'a str>)> {
|
|
let mut current = nodes;
|
|
let mut label = None;
|
|
for key in path {
|
|
let (items, l) = current.iter().find_map(|n| match n {
|
|
Node::Sub { key: k, label: l, items } if k == key => Some((items.as_slice(), l.as_str())),
|
|
_ => None,
|
|
})?;
|
|
current = items;
|
|
label = Some(l);
|
|
}
|
|
Some((current, label))
|
|
}
|
|
|
|
// ---------------------------------------------------------------- placement
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum Anchor {
|
|
/// The tray click point (physical).
|
|
Point { x: f64, y: f64 },
|
|
/// The tray icon's rectangle (physical): the root opens from the icon's edge that faces the screen, like the card.
|
|
Tray { x: f64, y: f64, w: f64, h: f64 },
|
|
/// Beside a parent panel: its left and right edge and the row's top (physical).
|
|
Beside { left: f64, right: f64, top: f64 },
|
|
/// A remembered top-left (pinned panels).
|
|
At { x: f64, y: f64 },
|
|
}
|
|
|
|
/// x, y, width, height (physical px).
|
|
pub type Rect = (f64, f64, f64, f64);
|
|
|
|
/// Keeps a panel of size w x h fully inside the work area (with `GAP`).
|
|
pub fn clamp_into(x: f64, y: f64, w: f64, h: f64, work: Rect) -> (f64, f64) {
|
|
let (wx, wy, ww, wh) = work;
|
|
(x.clamp(wx + GAP, (wx + ww - w - GAP).max(wx + GAP)), y.clamp(wy + GAP, (wy + wh - h - GAP).max(wy + GAP)))
|
|
}
|
|
|
|
/// Where a panel of `size` goes for `anchor`, inside the work area: the root opens away from the taskbar / menu bar the
|
|
/// click is near, a submenu beside its parent (flipped left at the screen edge), a pinned panel at its spot.
|
|
pub fn place_menu(anchor: Anchor, size: (f64, f64), work: Rect) -> (f64, f64) {
|
|
let (w, h) = size;
|
|
let (wx, wy, ww, wh) = work;
|
|
let (x, y) = match anchor {
|
|
Anchor::Point { x, y } => (if x + w > wx + ww { x - w } else { x }, if y + h > wy + wh { y - h } else { y }),
|
|
// same rule as the card: the panel's edge sits GAP from the icon, never over the taskbar
|
|
Anchor::Tray { x, y, w: iw, h: ih } => return crate::card::place(crate::card::Rect { x, y, w: iw, h: ih }, crate::card::Rect { x: wx, y: wy, w: ww, h: wh }, w, h, GAP),
|
|
Anchor::Beside { left, right, top } => (if right + w <= wx + ww - GAP { right - 2.0 } else { left - w + 2.0 }, top - 5.0),
|
|
Anchor::At { x, y } => (x, y),
|
|
};
|
|
clamp_into(x, y, w, h, work)
|
|
}
|
|
|
|
pub fn overlaps(a: Rect, b: Rect) -> bool {
|
|
a.0 < b.0 + b.2 && b.0 < a.0 + a.2 && a.1 < b.1 + b.3 && b.1 < a.1 + a.3
|
|
}
|
|
|
|
/// A spot for a newly pinned panel that covers no other pinned panel: below whatever it overlaps, then the next column.
|
|
/// Gives up after a while (a screen full of pins) and returns the clamped wish.
|
|
pub fn free_spot(want: Rect, others: &[Rect], work: Rect) -> (f64, f64) {
|
|
let (mut x, mut y, w, h) = want;
|
|
for _ in 0..64 {
|
|
let Some(hit) = others.iter().find(|o| overlaps((x, y, w, h), **o)) else {
|
|
return clamp_into(x, y, w, h, work);
|
|
};
|
|
y = hit.1 + hit.3 + GAP;
|
|
if y + h > work.1 + work.3 {
|
|
y = work.1 + GAP;
|
|
x += w + GAP;
|
|
if x + w > work.0 + work.2 {
|
|
x = work.0 + GAP;
|
|
}
|
|
}
|
|
}
|
|
clamp_into(want.0, want.1, w, h, work)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- panel book
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Panel {
|
|
pub path: Vec<String>,
|
|
pub pinned: bool,
|
|
pub parent: Option<String>,
|
|
pub anchor: Anchor,
|
|
/// Measured and shown once.
|
|
pub shown: bool,
|
|
/// Physical top-left once shown (kept for pins).
|
|
pub pos: Option<(f64, f64)>,
|
|
/// Opened from the keyboard: takes focus and starts on its first row.
|
|
pub focus: bool,
|
|
}
|
|
|
|
/// A pinned panel as state.json keeps it (`menuPins`).
|
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
pub struct PinRecord {
|
|
pub path: Vec<String>,
|
|
pub x: i32,
|
|
pub y: i32,
|
|
}
|
|
|
|
/// Which panels are open, which are pinned, who is whose child.
|
|
#[derive(Default, Debug)]
|
|
pub struct Book {
|
|
pub panels: BTreeMap<String, Panel>,
|
|
next: u32,
|
|
}
|
|
|
|
impl Book {
|
|
fn add(&mut self, path: Vec<String>, pinned: bool, parent: Option<String>, anchor: Anchor) -> String {
|
|
self.next += 1;
|
|
let label = format!("{LABEL_PREFIX}{}", self.next);
|
|
self.panels.insert(label.clone(), Panel { path, pinned, parent, anchor, shown: false, pos: None, focus: false });
|
|
label
|
|
}
|
|
|
|
fn remove_all(&mut self, labels: &[String]) {
|
|
for l in labels {
|
|
self.panels.remove(l);
|
|
}
|
|
}
|
|
|
|
pub fn transient(&self) -> Vec<String> {
|
|
self.panels.iter().filter(|(_, p)| !p.pinned).map(|(l, _)| l.clone()).collect()
|
|
}
|
|
|
|
/// Transient panels below `label` (children first).
|
|
pub fn descendants(&self, label: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
let mut frontier = vec![label.to_string()];
|
|
while let Some(l) = frontier.pop() {
|
|
for (child, p) in &self.panels {
|
|
if p.parent.as_deref() == Some(l.as_str()) && !p.pinned && !out.contains(child) {
|
|
out.push(child.clone());
|
|
frontier.push(child.clone());
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A fresh root menu: every transient panel closes, pinned ones stay. Returns the new label and the closed ones.
|
|
pub fn open_root(&mut self, anchor: Anchor) -> (String, Vec<String>) {
|
|
let closes = self.transient();
|
|
self.remove_all(&closes);
|
|
(self.add(vec![], false, None, anchor), closes)
|
|
}
|
|
|
|
/// Opens submenu `key` of `parent`; its other transient children close. None when that submenu already is the open
|
|
/// child, or the parent is gone. `pinned`: torn off as its own pinned panel (one pinned copy per submenu).
|
|
pub fn open_sub(&mut self, parent: &str, key: &str, anchor: Anchor, pinned: bool) -> Option<(String, Vec<String>)> {
|
|
let mut path = self.panels.get(parent)?.path.clone();
|
|
path.push(key.to_string());
|
|
if !pinned && self.panels.values().any(|p| p.parent.as_deref() == Some(parent) && !p.pinned && p.path == path) {
|
|
return None;
|
|
}
|
|
let mut closes = self.descendants(parent);
|
|
self.remove_all(&closes);
|
|
if pinned {
|
|
let old: Vec<String> = self.panels.iter().filter(|(_, p)| p.pinned && p.path == path).map(|(l, _)| l.clone()).collect();
|
|
for l in old {
|
|
closes.extend(self.close(&l));
|
|
}
|
|
return Some((self.add(path, true, None, anchor), closes));
|
|
}
|
|
Some((self.add(path, false, Some(parent.to_string()), anchor), closes))
|
|
}
|
|
|
|
/// Closes `label` and its transient children.
|
|
pub fn close(&mut self, label: &str) -> Vec<String> {
|
|
if !self.panels.contains_key(label) {
|
|
return vec![];
|
|
}
|
|
let mut out = self.descendants(label);
|
|
out.push(label.to_string());
|
|
self.remove_all(&out);
|
|
out
|
|
}
|
|
|
|
/// Every transient panel closes (Esc on the root, a pick, focus left the menus).
|
|
pub fn dismiss(&mut self) -> Vec<String> {
|
|
let t = self.transient();
|
|
self.remove_all(&t);
|
|
t
|
|
}
|
|
|
|
/// Pin or unpin. Pinning detaches the panel from its chain: the transient panels that are not its own children
|
|
/// close (returned). A second pinned copy of the same submenu closes too.
|
|
pub fn pin(&mut self, label: &str, on: bool) -> Vec<String> {
|
|
let Some(p) = self.panels.get(label) else { return vec![] };
|
|
let path = p.path.clone();
|
|
if !on {
|
|
if let Some(p) = self.panels.get_mut(label) {
|
|
p.pinned = false;
|
|
}
|
|
return vec![];
|
|
}
|
|
let mut closes: Vec<String> = self.panels.iter().filter(|(l, p)| p.pinned && p.path == path && l.as_str() != label).map(|(l, _)| l.clone()).collect();
|
|
if let Some(p) = self.panels.get_mut(label) {
|
|
p.pinned = true;
|
|
p.parent = None;
|
|
}
|
|
let mine = self.descendants(label);
|
|
closes.extend(self.transient().into_iter().filter(|l| l != label && !mine.contains(l)));
|
|
self.remove_all(&closes);
|
|
closes
|
|
}
|
|
|
|
/// The tree changed: transient panels whose submenu is gone close (with their children). Pinned ones stay and show
|
|
/// that their submenu is gone until it comes back (an app that is read again) or they are closed.
|
|
pub fn prune(&mut self, nodes: &[Node]) -> Vec<String> {
|
|
let gone: Vec<String> = self.panels.iter().filter(|(_, p)| !p.pinned && find(nodes, &p.path).is_none()).map(|(l, _)| l.clone()).collect();
|
|
let mut out = Vec::new();
|
|
for l in gone {
|
|
if self.panels.contains_key(&l) {
|
|
out.extend(self.close(&l));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The pinned panels to save (only placed ones; in label order, so the order is stable).
|
|
pub fn pins(&self) -> Vec<PinRecord> {
|
|
self.panels
|
|
.values()
|
|
.filter(|p| p.pinned)
|
|
.filter_map(|p| p.pos.map(|(x, y)| PinRecord { path: p.path.clone(), x: x.round() as i32, y: y.round() as i32 }))
|
|
.collect()
|
|
}
|
|
|
|
/// Recreates pinned panels from a saved list (duplicates and more than `MAX_PINS` dropped).
|
|
pub fn restore(&mut self, pins: &[PinRecord]) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
for r in pins.iter().take(MAX_PINS) {
|
|
if self.panels.values().any(|p| p.pinned && p.path == r.path) || r.path.len() > 8 {
|
|
continue;
|
|
}
|
|
let (x, y) = (r.x as f64, r.y as f64);
|
|
let l = self.add(r.path.clone(), true, None, Anchor::At { x, y });
|
|
if let Some(p) = self.panels.get_mut(&l) {
|
|
p.pos = Some((x, y));
|
|
}
|
|
out.push(l);
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// At most this many pinned panels are restored after a restart.
|
|
pub const MAX_PINS: usize = 12;
|
|
|
|
/// A panel label the app made (`menu-` and a number), for the window / command checks.
|
|
pub fn is_menu_label(label: &str) -> bool {
|
|
label.strip_prefix(LABEL_PREFIX).is_some_and(|n| !n.is_empty() && n.len() <= 9 && n.chars().all(|c| c.is_ascii_digit()))
|
|
}
|
|
|
|
// ---------------------------------------------------------------- runtime
|
|
|
|
#[derive(Serialize, Clone, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PanelInfo {
|
|
pub label: String,
|
|
pub path: Vec<String>,
|
|
pub pinned: bool,
|
|
pub has_parent: bool,
|
|
pub focus_first: bool,
|
|
pub nodes: Vec<Node>,
|
|
}
|
|
|
|
/// The panels' shared state (managed by the app).
|
|
#[derive(Default)]
|
|
pub struct MenuPanels {
|
|
pub book: Mutex<Book>,
|
|
pub nodes: Mutex<Vec<Node>>,
|
|
/// The native menu took over (a panel failed, or Linux).
|
|
pub native: AtomicBool,
|
|
/// Bumped on every move of a pinned panel; the save runs once the moves stop.
|
|
moves: AtomicU64,
|
|
}
|
|
|
|
#[cfg(desktop)]
|
|
mod runtime {
|
|
use super::*;
|
|
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
|
|
|
fn state(app: &AppHandle) -> tauri::State<'_, MenuPanels> {
|
|
app.state::<MenuPanels>()
|
|
}
|
|
|
|
fn book<R>(app: &AppHandle, f: impl FnOnce(&mut Book) -> R) -> R {
|
|
let s = state(app);
|
|
let mut b = s.book.lock().unwrap_or_else(|e| e.into_inner());
|
|
f(&mut b)
|
|
}
|
|
|
|
fn nodes(app: &AppHandle) -> Vec<Node> {
|
|
state(app).nodes.lock().map(|n| n.clone()).unwrap_or_default()
|
|
}
|
|
|
|
pub fn native_fallback(app: &AppHandle) -> bool {
|
|
state(app).native.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Puts the native menu (from the current tree) on the tray: the fallback, and Linux.
|
|
pub fn set_native(app: &AppHandle) {
|
|
let Some(tray) = app.tray_by_id("main") else { return };
|
|
match crate::tray::native_menu(app, &nodes(app)) {
|
|
Ok(m) => {
|
|
let _ = tray.set_menu(Some(m));
|
|
}
|
|
Err(e) => crate::logs::host("warn", &format!("native tray menu not built: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Writes the pinned panels into state.json (`menuPins`).
|
|
fn save_pins(app: &AppHandle) {
|
|
let pins = book(app, |b| b.pins());
|
|
let Some(s) = app.try_state::<crate::commands::AppState>() else { return };
|
|
let Ok(mut st) = s.saved.lock() else { return };
|
|
if st.menu_pins == pins {
|
|
return;
|
|
}
|
|
st.menu_pins = pins;
|
|
if let Err(e) = crate::store::save(&s.data_dir, &st) {
|
|
crate::logs::host("warn", &format!("menu pins not saved: {e}"));
|
|
}
|
|
}
|
|
|
|
/// Stores the latest tree and pushes it to every open panel (they update in place). Panels of a submenu that is gone
|
|
/// close when transient. Cheap when nothing changed or no panel is open.
|
|
pub fn set_nodes(app: &AppHandle, next: Vec<Node>) {
|
|
let changed = {
|
|
let s = state(app);
|
|
let mut g = s.nodes.lock().unwrap_or_else(|e| e.into_inner());
|
|
let changed = *g != next;
|
|
*g = next.clone();
|
|
changed
|
|
};
|
|
if !changed {
|
|
return;
|
|
}
|
|
let (closes, open) = book(app, |b| (b.prune(&next), b.panels.keys().cloned().collect::<Vec<_>>()));
|
|
destroy(app, &closes);
|
|
for l in open {
|
|
let _ = app.emit_to(l.as_str(), MODEL_EVENT, &next);
|
|
}
|
|
if native_fallback(app) {
|
|
set_native(app);
|
|
}
|
|
}
|
|
|
|
fn destroy(app: &AppHandle, labels: &[String]) {
|
|
for l in labels {
|
|
if let Some(w) = app.get_webview_window(l) {
|
|
let _ = w.destroy();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_window(app: &AppHandle, label: &str) -> tauri::Result<WebviewWindow> {
|
|
let b = WebviewWindowBuilder::new(app, label, WebviewUrl::App(PAGE.into()))
|
|
.title("storebound")
|
|
.inner_size(EST.0, EST.1)
|
|
.decorations(false)
|
|
.always_on_top(true)
|
|
.skip_taskbar(true)
|
|
.resizable(false)
|
|
.shadow(true)
|
|
.visible(false)
|
|
.focused(false);
|
|
#[cfg(not(target_os = "macos"))]
|
|
let b = b.transparent(true);
|
|
let window = b.build()?;
|
|
#[cfg(target_os = "macos")]
|
|
let _ = window.set_visible_on_all_workspaces(true);
|
|
let handle = app.clone();
|
|
let me = label.to_string();
|
|
window.on_window_event(move |event| match event {
|
|
tauri::WindowEvent::Focused(false) => {
|
|
let handle = handle.clone();
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(Duration::from_millis(160));
|
|
close_transient_unless_focused(&handle);
|
|
});
|
|
}
|
|
tauri::WindowEvent::Moved(pos) => {
|
|
let pinned = book(&handle, |b| match b.panels.get_mut(&me) {
|
|
Some(p) if p.shown => {
|
|
p.pos = Some((pos.x as f64, pos.y as f64));
|
|
p.pinned
|
|
}
|
|
_ => false,
|
|
});
|
|
if pinned {
|
|
// a drag sends many moves: save once they stop
|
|
let n = state(&handle).moves.fetch_add(1, Ordering::Relaxed) + 1;
|
|
let handle = handle.clone();
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(Duration::from_millis(400));
|
|
if state(&handle).moves.load(Ordering::Relaxed) == n {
|
|
save_pins(&handle);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
});
|
|
Ok(window)
|
|
}
|
|
|
|
/// Focus left a menu window: every transient panel closes unless one of them has focus now.
|
|
fn close_transient_unless_focused(app: &AppHandle) {
|
|
let transient = book(app, |b| b.transient());
|
|
if transient.iter().any(|l| app.get_webview_window(l).and_then(|w| w.is_focused().ok()).unwrap_or(false)) {
|
|
return;
|
|
}
|
|
book(app, |b| {
|
|
for l in &transient {
|
|
b.panels.remove(l);
|
|
}
|
|
});
|
|
destroy(app, &transient);
|
|
}
|
|
|
|
/// The panels cannot be shown: the native menu goes on the tray and opens once now.
|
|
fn native_popup(app: &AppHandle) {
|
|
if !state(app).native.swap(true, Ordering::Relaxed) {
|
|
crate::logs::host("warn", "tray menu panels unavailable: the native menu is used");
|
|
}
|
|
set_native(app);
|
|
let handle = app.clone();
|
|
let _ = app.run_on_main_thread(move || {
|
|
if let (Some(w), Ok(menu)) = (handle.get_webview_window(crate::policy::MAIN), crate::tray::native_menu(&handle, &nodes(&handle))) {
|
|
let _ = w.popup_menu(&menu);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// A right click on the tray icon: a fresh root menu next to the icon (`anchor`: Anchor::Tray, physical px).
|
|
pub fn open_root(app: &AppHandle, anchor: Anchor) {
|
|
if native_fallback(app) {
|
|
return; // the native menu is on the tray and opens by itself
|
|
}
|
|
let (label, closes) = book(app, |b| b.open_root(anchor));
|
|
destroy(app, &closes);
|
|
let handle = app.clone();
|
|
std::thread::spawn(move || {
|
|
if let Err(e) = build_window(&handle, &label) {
|
|
crate::logs::host("warn", &format!("tray menu panel not built: {e}"));
|
|
book(&handle, |b| b.panels.remove(&label));
|
|
native_popup(&handle);
|
|
return;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(2500));
|
|
if book(&handle, |b| b.panels.get(&label).map(|p| p.shown)) == Some(false) {
|
|
crate::logs::host("warn", "tray menu panel never reported ready");
|
|
let closes = book(&handle, |b| b.close(&label));
|
|
destroy(&handle, &closes);
|
|
native_popup(&handle);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Brings back the pinned panels of the last session (state.json `menuPins`).
|
|
pub fn restore(app: &AppHandle) {
|
|
let pins = app.try_state::<crate::commands::AppState>().and_then(|s| s.saved.lock().ok().map(|st| st.menu_pins.clone())).unwrap_or_default();
|
|
if pins.is_empty() || native_fallback(app) {
|
|
return;
|
|
}
|
|
let labels = book(app, |b| b.restore(&pins));
|
|
let handle = app.clone();
|
|
std::thread::spawn(move || {
|
|
for l in labels {
|
|
if build_window(&handle, &l).is_err() {
|
|
book(&handle, |b| b.panels.remove(&l));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn work_area(app: &AppHandle, x: f64, y: f64) -> Option<Rect> {
|
|
let m = app.monitor_from_point(x, y).ok().flatten().or_else(|| app.primary_monitor().ok().flatten())?;
|
|
let w = m.work_area();
|
|
Some((w.position.x as f64, w.position.y as f64, w.size.width as f64, w.size.height as f64))
|
|
}
|
|
|
|
fn anchor_point(a: Anchor) -> (f64, f64) {
|
|
match a {
|
|
Anchor::Point { x, y } | Anchor::At { x, y } => (x, y),
|
|
Anchor::Tray { x, y, w, h } => (x + w / 2.0, y + h / 2.0),
|
|
Anchor::Beside { right, top, .. } => (right, top),
|
|
}
|
|
}
|
|
|
|
fn outer_rect(w: &WebviewWindow) -> Option<Rect> {
|
|
let p = w.outer_position().ok()?;
|
|
let s = w.outer_size().ok()?;
|
|
Some((p.x as f64, p.y as f64, s.width as f64, s.height as f64))
|
|
}
|
|
|
|
fn pinned_rects(app: &AppHandle, skip: &str) -> Vec<Rect> {
|
|
let labels: Vec<String> = book(app, |b| b.panels.iter().filter(|(l, p)| p.pinned && l.as_str() != skip).map(|(l, _)| l.clone()).collect());
|
|
labels.iter().filter_map(|l| app.get_webview_window(l).and_then(|w| outer_rect(&w))).collect()
|
|
}
|
|
|
|
// ---- what the panel page calls (commands.rs checks the window first) ----
|
|
|
|
pub fn panel_state(window: &WebviewWindow) -> Option<PanelInfo> {
|
|
let app = window.app_handle();
|
|
let p = book(app, |b| b.panels.get(window.label()).cloned())?;
|
|
Some(PanelInfo { label: window.label().into(), path: p.path, pinned: p.pinned, has_parent: p.parent.is_some(), focus_first: p.focus, nodes: nodes(app) })
|
|
}
|
|
|
|
/// The panel measured itself (CSS px): size it, place it inside the work area, show it the first time.
|
|
pub fn ready(window: &WebviewWindow, width: f64, height: f64) {
|
|
let app = window.app_handle().clone();
|
|
let Some(panel) = book(&app, |b| b.panels.get(window.label()).cloned()) else { return };
|
|
let scale = window.scale_factor().unwrap_or(1.0);
|
|
let size = ((width.clamp(120.0, 600.0) * scale).ceil(), (height.clamp(40.0, 900.0) * scale).ceil());
|
|
let (ax, ay) = panel.pos.unwrap_or_else(|| anchor_point(panel.anchor));
|
|
let Some(work) = work_area(&app, ax, ay) else { return };
|
|
let (x, y) = if panel.shown {
|
|
let (x, y, _, _) = outer_rect(window).unwrap_or((ax, ay, 0.0, 0.0));
|
|
clamp_into(x, y, size.0, size.1, work)
|
|
} else if let Some((x, y)) = panel.pos {
|
|
clamp_into(x, y, size.0, size.1, work)
|
|
} else {
|
|
place_menu(panel.anchor, size, work)
|
|
};
|
|
let _ = window.set_size(PhysicalSize::new(size.0 as u32, size.1 as u32));
|
|
let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32));
|
|
if !panel.shown {
|
|
book(&app, |b| {
|
|
if let Some(p) = b.panels.get_mut(window.label()) {
|
|
p.shown = true;
|
|
p.pos = Some((x, y));
|
|
}
|
|
});
|
|
let _ = window.show();
|
|
if panel.focus || (panel.parent.is_none() && !panel.pinned) {
|
|
let _ = window.set_focus();
|
|
}
|
|
if panel.pinned {
|
|
save_pins(&app);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Opens submenu `key` beside this panel at the row whose top is `top` (CSS px). `pinned`: torn off (Ctrl / middle
|
|
/// click), placed so it covers no other pinned panel.
|
|
pub fn open_sub(window: &WebviewWindow, key: &str, top: f64, focus: bool, pinned: bool) {
|
|
let app = window.app_handle().clone();
|
|
let Some((wx, wy, ww, _)) = outer_rect(window) else { return };
|
|
let scale = window.scale_factor().unwrap_or(1.0);
|
|
let anchor = Anchor::Beside { left: wx, right: wx + ww, top: wy + top.max(0.0) * scale };
|
|
let Some((label, closes)) = book(&app, |b| b.open_sub(window.label(), key, anchor, pinned)) else { return };
|
|
destroy(&app, &closes);
|
|
if pinned {
|
|
let others = pinned_rects(&app, &label);
|
|
if let Some(work) = work_area(&app, wx, wy) {
|
|
let est = (EST.0 * scale, EST.1 * scale);
|
|
let (x, y) = place_menu(anchor, est, work);
|
|
let spot = free_spot((x, y, est.0, est.1), &others, work);
|
|
book(&app, |b| {
|
|
if let Some(p) = b.panels.get_mut(&label) {
|
|
p.pos = Some(spot);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
book(&app, |b| {
|
|
if let Some(p) = b.panels.get_mut(&label) {
|
|
p.focus = focus;
|
|
}
|
|
});
|
|
let handle = app.clone();
|
|
std::thread::spawn(move || {
|
|
if build_window(&handle, &label).is_err() {
|
|
book(&handle, |b| b.panels.remove(&label));
|
|
}
|
|
});
|
|
}
|
|
|
|
/// The pointer moved to a plain row: this panel's transient submenus close.
|
|
pub fn close_subs(window: &WebviewWindow) {
|
|
let app = window.app_handle().clone();
|
|
let closes = book(&app, |b| {
|
|
let d = b.descendants(window.label());
|
|
for l in &d {
|
|
b.panels.remove(l);
|
|
}
|
|
d
|
|
});
|
|
destroy(&app, &closes);
|
|
}
|
|
|
|
/// Closes this panel (Left / Esc in a submenu, X on a pinned one).
|
|
pub fn close(window: &WebviewWindow, refocus_parent: bool) {
|
|
let app = window.app_handle().clone();
|
|
let (closes, parent, was_pinned) = book(&app, |b| {
|
|
let p = b.panels.get(window.label()).cloned();
|
|
(b.close(window.label()), p.as_ref().and_then(|p| p.parent.clone()), p.is_some_and(|p| p.pinned))
|
|
});
|
|
if refocus_parent {
|
|
if let Some(w) = parent.and_then(|p| app.get_webview_window(&p)) {
|
|
let _ = w.set_focus();
|
|
let _ = app.emit_to(w.label(), PANEL_EVENT, "focus");
|
|
}
|
|
}
|
|
destroy(&app, &closes);
|
|
if was_pinned {
|
|
save_pins(&app);
|
|
}
|
|
}
|
|
|
|
/// Esc on the root menu: every transient panel closes.
|
|
pub fn dismiss(app: &AppHandle) {
|
|
let closes = book(app, |b| b.dismiss());
|
|
destroy(app, &closes);
|
|
}
|
|
|
|
/// Pin or unpin this panel. Pinning steps it aside from the other pinned panels.
|
|
pub fn pin(window: &WebviewWindow, on: bool) {
|
|
let app = window.app_handle().clone();
|
|
let label = window.label().to_string();
|
|
let closes = book(&app, |b| b.pin(&label, on));
|
|
destroy(&app, &closes);
|
|
if on {
|
|
let others = pinned_rects(&app, &label);
|
|
if let Some(me) = outer_rect(window) {
|
|
if let Some(work) = work_area(&app, me.0, me.1) {
|
|
let (x, y) = free_spot(me, &others, work);
|
|
if (x, y) != (me.0, me.1) {
|
|
let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32));
|
|
}
|
|
book(&app, |b| {
|
|
if let Some(p) = b.panels.get_mut(&label) {
|
|
p.pos = Some((x, y));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
let _ = app.emit_to(label.as_str(), PANEL_EVENT, if on { "pinned" } else { "unpinned" });
|
|
save_pins(&app);
|
|
}
|
|
|
|
/// A pick: the same action as the native menu. Transient panels close; pinned ones stay and update in place.
|
|
pub fn select(window: &WebviewWindow, id: String) {
|
|
let app = window.app_handle().clone();
|
|
let pinned = book(&app, |b| b.panels.get(window.label()).map(|p| p.pinned).unwrap_or(false));
|
|
if !pinned {
|
|
dismiss(&app);
|
|
}
|
|
let handle = app.clone();
|
|
let _ = app.run_on_main_thread(move || crate::dispatch_menu(&handle, &id));
|
|
}
|
|
}
|
|
|
|
#[cfg(desktop)]
|
|
pub use runtime::*;
|