desktop: tray menu panels and pinnable context menus

The tray's right-click menu is drawn by the app: one tree from tray.rs
(stable submenu keys), one frameless always-on-top menu-N window per
panel rendered by tray-menu.html with the design system, placed inside
the monitor's work area. A pure panel book keeps transient and pinned
panels apart; pinned panels (pin button, Ctrl+click, middle click) are
draggable, step aside from each other and are saved in state.json
(menuPins) and restored at start. tray_update pushes the tree to the
open panels, which update in place; closed panels are destroyed. The
native menu, rendered from the same tree, stays as the fallback and on
Linux. Only menu-* windows get the menu_* commands (menu.json).

In-app context menus get the same behaviour through a pure menu stack
(ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels
survive the next menu, are draggable and re-read their entries from
live sources so data changes update them in place.

Also drops a stray diff3 marker line from CHANGELOG.md.
This commit is contained in:
CRTkafa 2026-10-24 13:44:08 +08:00
parent 445ab1adbc
commit 5236f002f1
35 changed files with 2308 additions and 123 deletions

View file

@ -1,7 +1,7 @@
//! 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
//! (pure, tested in tests/tray.rs), pushes it to the menu panels (menu_panel.rs), 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`).
//!
@ -14,7 +14,9 @@ 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::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::menu_panel::{from_entries, Node};
use tauri::{AppHandle, Runtime};
pub const ID_OPEN: &str = "tray-open";
@ -88,7 +90,8 @@ pub struct TraySummary {
pub enum Entry {
Item { id: String, label: String, enabled: bool },
Separator,
Sub { label: String, items: Vec<Entry> },
/// `key`: the submenu's fixed key (menu_panel::sub_key), the same in every language.
Sub { key: String, label: String, items: Vec<Entry> },
}
fn clean(s: &str, max: usize) -> String {
@ -132,7 +135,7 @@ pub fn menu_model(s: &TraySummary) -> Vec<Entry> {
item(ID_OPEN, &l.open),
item(ID_READ, &l.read_now),
Entry::Separator,
Entry::Sub { label: clean(&l.apps, 40), items: apps },
Entry::Sub { key: "apps".into(), 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),
@ -233,40 +236,45 @@ pub fn take_pending() -> Option<String> {
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)?;
/// The native menu items for a panel tree (the fallback, and Linux), nested to any depth.
fn native_items<R: Runtime>(app: &AppHandle<R>, nodes: &[Node]) -> tauri::Result<Vec<Box<dyn IsMenuItem<R>>>> {
let mut out: Vec<Box<dyn IsMenuItem<R>>> = Vec::new();
for n in nodes {
match n {
Node::Item { id, label, enabled } => out.push(Box::new(MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)),
Node::Sep => out.push(Box::new(PredefinedMenuItem::separator(app)?)),
Node::Sub { label, items, .. } => {
let children = native_items(app, items)?;
let refs: Vec<&dyn IsMenuItem<R>> = children.iter().map(|b| b.as_ref()).collect();
out.push(Box::new(Submenu::with_items(app, label, true, &refs)?));
}
}
}
Ok(menu)
Ok(out)
}
/// 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()))
/// The native tray menu, rendered from the same tree as the panels.
pub fn native_menu<R: Runtime>(app: &AppHandle<R>, nodes: &[Node]) -> tauri::Result<Menu<R>> {
let items = native_items(app, nodes)?;
let refs: Vec<&dyn IsMenuItem<R>> = items.iter().map(|b| b.as_ref()).collect();
Menu::with_items(app, &refs)
}
/// 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<()> {
/// The tree before the window has sent anything (English words).
pub fn initial_nodes() -> Vec<Node> {
from_entries(&menu_model(&TraySummary::default()))
}
/// Applies a summary: tooltip and icon of the tray with id `main`, and the menu tree, which goes live to the open menu
/// panels (and to the native menu when that is in use). Skips the work when nothing changed.
pub fn apply(app: &AppHandle, 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))?))?;
crate::menu_panel::set_nodes(app, from_entries(&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))?;