desktop tray: card as tall as its content, card and menu open from the tray icon's edge (never over the taskbar); menu permission files

This commit is contained in:
CRTkafa 2026-10-24 19:53:06 +08:00
parent 6782ac6078
commit da7ca48ab4
17 changed files with 185 additions and 26 deletions

View file

@ -62,7 +62,7 @@ pub fn place(tray: Rect, work: Rect, card_w: f64, card_h: f64, gap: f64) -> (f64
/// 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")
matches!(a, "hide" | "open" | "route" | "refresh" | "mute" | "quit" | "external" | "ready" | "size")
}
// ---- the last snapshot ----
@ -123,6 +123,38 @@ mod window {
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 };
@ -134,14 +166,9 @@ mod window {
if just_hidden() {
return;
}
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 (x, y) = place(tray, work, WIDTH * s, HEIGHT * s, GAP * s);
let _ = w.set_position(PhysicalPosition::new(x as i32, y as i32));
}
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", ());
@ -158,4 +185,4 @@ mod window {
}
#[cfg(desktop)]
pub use window::{create, hide, toggle};
pub use window::{create, hide, resize, toggle};

View file

@ -1355,6 +1355,10 @@ pub fn card_action(window: WebviewWindow, app: AppHandle, action: String, arg: O
match action.as_str() {
"hide" => crate::card::hide(&app),
"ready" => { let _ = app.emit_to(policy::MAIN, "card-request", ()); }
"size" => {
let h: f64 = arg.as_deref().unwrap_or_default().parse().map_err(|_| Fail::host("bad size"))?;
crate::card::resize(&app, h);
}
"open" => { crate::card::hide(&app); main(); }
"refresh" => { let _ = app.emit_to(policy::MAIN, "tray-action", "read"); }
"mute" => { let _ = app.emit_to(policy::MAIN, "tray-action", "mute-toggle"); }

View file

@ -67,19 +67,20 @@ fn tray(app: &tauri::App) -> tauri::Result<()> {
.on_tray_icon_event(|t, e| {
let TrayIconEvent::Click { button, button_state: MouseButtonState::Up, rect, position, .. } = e else { return };
let app = t.app_handle();
// both open from the icon's rectangle (not the click point): their edge sits next to the icon, above a
// bottom taskbar, so nothing starts inside the taskbar
let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0);
let p = rect.position.to_physical::<f64>(scale);
let s = rect.size.to_physical::<f64>(scale);
let icon = if s.width > 0.0 && s.height > 0.0 { card::Rect { x: p.x, y: p.y, w: s.width, h: s.height } } else { card::Rect { x: position.x, y: position.y, w: 1.0, h: 1.0 } };
match button {
// right click: the menu panels at the click point
MouseButton::Right => menu_panel::open_root(app, position.x, position.y),
// left click: the tray card next to the icon
MouseButton::Right => menu_panel::open_root(app, menu_panel::Anchor::Tray { x: icon.x, y: icon.y, w: icon.w, h: icon.h }),
MouseButton::Left => {
if app.get_webview_window(card::LABEL).is_none() {
show_main(app);
return;
}
let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0);
let p = rect.position.to_physical::<f64>(scale);
let s = rect.size.to_physical::<f64>(scale);
card::toggle(app, card::Rect { x: p.x, y: p.y, w: s.width, h: s.height });
card::toggle(app, icon);
}
_ => {}
}

View file

@ -107,6 +107,8 @@ pub fn find<'a>(nodes: &'a [Node], path: &[String]) -> Option<(&'a [Node], Optio
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).
@ -129,6 +131,8 @@ pub fn place_menu(anchor: Anchor, size: (f64, f64), work: Rect) -> (f64, f64) {
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),
};
@ -520,12 +524,12 @@ mod runtime {
});
}
/// A right click on the tray icon: a fresh root menu at the click point (physical px).
pub fn open_root(app: &AppHandle, x: f64, y: f64) {
/// 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::Point { x, y }));
let (label, closes) = book(app, |b| b.open_root(anchor));
destroy(app, &closes);
let handle = app.clone();
std::thread::spawn(move || {
@ -571,6 +575,7 @@ mod runtime {
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),
}
}