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

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-close"
description = "Enables the menu_close command without any pre-configured scope."
commands.allow = ["menu_close"]
[[permission]]
identifier = "deny-menu-close"
description = "Denies the menu_close command without any pre-configured scope."
commands.deny = ["menu_close"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-close-subs"
description = "Enables the menu_close_subs command without any pre-configured scope."
commands.allow = ["menu_close_subs"]
[[permission]]
identifier = "deny-menu-close-subs"
description = "Denies the menu_close_subs command without any pre-configured scope."
commands.deny = ["menu_close_subs"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-dismiss"
description = "Enables the menu_dismiss command without any pre-configured scope."
commands.allow = ["menu_dismiss"]
[[permission]]
identifier = "deny-menu-dismiss"
description = "Denies the menu_dismiss command without any pre-configured scope."
commands.deny = ["menu_dismiss"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-open-sub"
description = "Enables the menu_open_sub command without any pre-configured scope."
commands.allow = ["menu_open_sub"]
[[permission]]
identifier = "deny-menu-open-sub"
description = "Denies the menu_open_sub command without any pre-configured scope."
commands.deny = ["menu_open_sub"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-pin"
description = "Enables the menu_pin command without any pre-configured scope."
commands.allow = ["menu_pin"]
[[permission]]
identifier = "deny-menu-pin"
description = "Denies the menu_pin command without any pre-configured scope."
commands.deny = ["menu_pin"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-pin-sub"
description = "Enables the menu_pin_sub command without any pre-configured scope."
commands.allow = ["menu_pin_sub"]
[[permission]]
identifier = "deny-menu-pin-sub"
description = "Denies the menu_pin_sub command without any pre-configured scope."
commands.deny = ["menu_pin_sub"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-ready"
description = "Enables the menu_ready command without any pre-configured scope."
commands.allow = ["menu_ready"]
[[permission]]
identifier = "deny-menu-ready"
description = "Denies the menu_ready command without any pre-configured scope."
commands.deny = ["menu_ready"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-select"
description = "Enables the menu_select command without any pre-configured scope."
commands.allow = ["menu_select"]
[[permission]]
identifier = "deny-menu-select"
description = "Denies the menu_select command without any pre-configured scope."
commands.deny = ["menu_select"]

View file

@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-menu-state"
description = "Enables the menu_state command without any pre-configured scope."
commands.allow = ["menu_state"]
[[permission]]
identifier = "deny-menu-state"
description = "Denies the menu_state command without any pre-configured scope."
commands.deny = ["menu_state"]

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),
}
}

View file

@ -83,6 +83,17 @@ fn root_opens_away_from_the_taskbar_and_stays_inside() {
assert_eq!(y, GAP);
}
#[test]
fn tray_root_sits_just_above_the_icon_never_over_the_taskbar() {
// icon in a bottom taskbar (the icon is below the work area): bottom edge exactly GAP above the icon's top
let icon = Anchor::Tray { x: 1800.0, y: 1044.0, w: 32.0, h: 32.0 };
let (_, y) = place_menu(icon, (260.0, 300.0), WORK);
assert_eq!(y + 300.0, 1040.0 - GAP);
// even when the reported work area wrongly includes the taskbar, the panel stays above the icon
let (_, y) = place_menu(icon, (260.0, 300.0), (0.0, 0.0, 1920.0, 1080.0));
assert!(y + 300.0 <= 1044.0 - GAP);
}
#[test]
fn submenus_open_beside_and_flip_left_at_the_edge() {
let (x, _) = place_menu(Anchor::Beside { left: 1600.0, right: 1880.0, top: 100.0 }, (280.0, 200.0), WORK);

View file

@ -18,7 +18,7 @@ export interface CardHost {
get: () => Promise<CardSnapshot | null>;
onSnapshot: (fn: (s: CardSnapshot) => void) => () => void;
onShown: (fn: () => void) => () => void;
action: (action: 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready', arg?: string) => void;
action: (action: 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready' | 'size', arg?: string) => void;
}
const route = (app: string, tab: 'releases' | 'reviews' | 'health') => JSON.stringify({ screen: 'app', appId: app, tab });
@ -39,6 +39,18 @@ export function Card({ host, lang }: { host: CardHost; lang: CardLang }) {
const tm = setInterval(() => setNow(Date.now()), 30_000);
return () => { a(); b(); clearInterval(tm); };
}, [host]);
// the window is as tall as the card: report the height whenever it changes (the host re-anchors it above the icon)
useEffect(() => {
const el = root.current;
if (!el || typeof ResizeObserver === 'undefined') return;
let last = 0;
const ro = new ResizeObserver(() => {
const h = Math.ceil(el.getBoundingClientRect().height);
if (h > 0 && h !== last) { last = h; host.action('size', String(h)); }
});
ro.observe(el);
return () => ro.disconnect();
}, [host]);
// every show: fade in, focus the first control (keyboard users start at Refresh)
useEffect(() => { (root.current?.querySelector<HTMLElement>('button:not([disabled])') ?? root.current)?.focus(); }, [shown, !!s]);

View file

@ -4,7 +4,7 @@ import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type { CardSnapshot } from './model';
export type CardAction = 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready';
export type CardAction = 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready' | 'size';
export const inTauri = (): boolean => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;

View file

@ -1,12 +1,12 @@
/* The tray card (card.html): v2 language at 360 x 480, rounded, its own surface; fades in over 120 ms on every show. */
/* The tray card (card.html): v2 language, 360 wide and as tall as its content (at most 480: the body scrolls), rounded, its own surface; fades in over 120 ms on every show. */
html:has(body.tc-page), body.tc-page { margin: 0; height: 100%; background: transparent; overflow: hidden; }
body.tc-page { font: var(--ds-t-body)/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); }
body.tc-page #card { height: 100%; }
body.tc-page #card { height: auto; }
body.tc-page.tc-in .tc { animation: tc-in 120ms ease-out; }
:root[data-motion="reduce"] body.tc-page.tc-in .tc { animation: none; }
@keyframes tc-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.tc { box-sizing: border-box; height: 100%; max-height: 480px; display: flex; flex-direction: column; border-radius: var(--ds-r-lg);
.tc { box-sizing: border-box; height: auto; max-height: 480px; display: flex; flex-direction: column; border-radius: var(--ds-r-lg);
border: 1px solid var(--ds-stroke); background: radial-gradient(120% 60% at 100% 0%, color-mix(in srgb, var(--accent-solid) 10%, transparent), transparent 60%), var(--ds-raised); overflow: hidden; outline: none; }
.tc-head { position: relative; display: flex; align-items: center; gap: 8px; padding: 14px 12px 12px 16px; border-bottom: 1px solid var(--ds-stroke); }
.tc-title { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@ -25,7 +25,7 @@ body.tc-page.tc-in .tc { animation: tc-in 120ms ease-out; }
.tc-menu button:hover, .tc-menu button:focus-visible { background: var(--hover); outline: none; }
.tc-menu hr { border: 0; border-top: 1px solid var(--ds-stroke); margin: 4px 2px; }
.tc-body { flex: 1; overflow-y: auto; padding: 6px 10px 12px; scrollbar-width: thin; }
.tc-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 10px 12px; scrollbar-width: thin; }
.tc-empty { margin: 28px 16px; text-align: center; color: var(--muted); }
.tc-sec { margin-top: 8px; }
.tc-sec h2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 6px 6px 4px; font-size: var(--ds-t-micro); font-weight: var(--ds-w-strong); text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }