//! 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 }, } /// 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) -> Vec { let mut seen: BTreeMap = 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 { 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, pub pinned: bool, pub parent: Option, 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, 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, next: u32, } impl Book { fn add(&mut self, path: Vec, pinned: bool, parent: Option, 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 { 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 { 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) { 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)> { 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 = 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 { 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 { 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 { 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 = 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 { let gone: Vec = 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 { 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 { 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, pub pinned: bool, pub has_parent: bool, pub focus_first: bool, pub nodes: Vec, } /// The panels' shared state (managed by the app). #[derive(Default)] pub struct MenuPanels { pub book: Mutex, pub nodes: Mutex>, /// 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::() } fn book(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 { 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::() 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) { 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::>())); 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 { 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::().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 { 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 { 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 { let labels: Vec = 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 { 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::*;