From c1368f19c47573ed89cb0aa71b898bf59fa9f280 Mon Sep 17 00:00:00 2001 From: eerraa Date: Sat, 19 Sep 2026 12:56:34 +0900 Subject: [PATCH] fix(tray): constrain popup to the clicked monitor work area --- scripts/tray-popup.test.mjs | 18 ++++++ src-tauri/capabilities/tray.json | 7 +++ src-tauri/src/lib.rs | 1 + src-tauri/src/tray.rs | 47 ++++++++++---- src-tauri/src/tray_position.rs | 102 +++++++++++++++++++++++++++++++ src/components/TrayPopup.css | 4 +- src/components/TrayPopup.tsx | 2 - 7 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 scripts/tray-popup.test.mjs create mode 100644 src-tauri/capabilities/tray.json create mode 100644 src-tauri/src/tray_position.rs diff --git a/scripts/tray-popup.test.mjs b/scripts/tray-popup.test.mjs new file mode 100644 index 0000000..f1c2547 --- /dev/null +++ b/scripts/tray-popup.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +test('tray grants only event subscriptions and relies on native main-window command to hide', () => { + const cap = JSON.parse(fs.readFileSync('src-tauri/capabilities/tray.json', 'utf8')); + assert.deepEqual(cap.windows, ['tray-popup']); + assert.deepEqual(cap.permissions, ['core:event:allow-listen', 'core:event:allow-unlisten']); + const source = fs.readFileSync('src/components/TrayPopup.tsx', 'utf8'); + assert.ok(source.includes("invoke('show_main_window_cmd')")); + assert.ok(!source.includes('getCurrentWebviewWindow().hide')); +}); + +test('small work-area popup retains vertical access to action buttons', () => { + const css = fs.readFileSync('src/components/TrayPopup.css', 'utf8'); + assert.match(css, /\.tray-popup\s*\{[^}]*overflow-y:\s*auto/s); + assert.match(css, /\.tp-actions\s*\{[^}]*flex-shrink:\s*0/s); +}); diff --git a/src-tauri/capabilities/tray.json b/src-tauri/capabilities/tray.json new file mode 100644 index 0000000..cf60fa3 --- /dev/null +++ b/src-tauri/capabilities/tray.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "tray-events", + "description": "Event subscription for the local tray popup; no filesystem access", + "windows": ["tray-popup"], + "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ae6eb2..886e566 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,6 +34,7 @@ mod sse_watchdog; mod switch_log; mod token_tracker; mod tray; +mod tray_position; mod usage; use account::{Account, AccountStore}; diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index e89a1aa..a80d036 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -98,7 +98,10 @@ fn toggle_popup(app: &AppHandle, position: tauri::PhysicalPosition) { return; } // 重新定位并显示 - let _ = position_popup(&win, position); + if let Err(error) = position_popup(&win, position) { + eprintln!("[Tray] Position failed: {}", error); + return; + } let _ = win.show(); let _ = win.set_focus(); return; @@ -131,7 +134,10 @@ fn toggle_popup(app: &AppHandle, position: tauri::PhysicalPosition) { } }); - let _ = position_popup(&win, position); + if let Err(error) = position_popup(&win, position) { + eprintln!("[Tray] Position failed: {}", error); + return; + } let _ = win.show(); let _ = win.set_focus(); } @@ -139,21 +145,36 @@ fn toggle_popup(app: &AppHandle, position: tauri::PhysicalPosition) { } } -/// 将 popup 窗口定位到托盘图标附近(macOS 顶部菜单栏下方) +/// Use the clicked monitor's physical work area, including its taskbar and DPI. fn position_popup( win: &tauri::WebviewWindow, tray_pos: tauri::PhysicalPosition, ) -> Result<(), String> { - let popup_width = 380.0; - - let scale = win.scale_factor().unwrap_or(1.0); - - let x = (tray_pos.x - popup_width * scale / 2.0).max(0.0) as i32; - let y = (tray_pos.y + 4.0) as i32; // 留一点间距给菜单栏 - - let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition::new( - x, y, - ))); + let monitor = match win + .monitor_from_point(tray_pos.x, tray_pos.y) + .map_err(|e| e.to_string())? + { + Some(monitor) => monitor, + None => match win.current_monitor().map_err(|e| e.to_string())? { + Some(monitor) => monitor, + None => win + .primary_monitor() + .map_err(|e| e.to_string())? + .ok_or("No monitor available for tray popup")?, + }, + }; + let area = monitor.work_area(); + let rect = crate::tray_position::place( + (tray_pos.x, tray_pos.y), + (area.position.x, area.position.y), + (area.size.width, area.size.height), + monitor.scale_factor(), + ) + .ok_or("Invalid tray monitor work area")?; + win.set_position(tauri::PhysicalPosition::new(rect.x, rect.y)) + .map_err(|e| e.to_string())?; + win.set_size(tauri::PhysicalSize::new(rect.width, rect.height)) + .map_err(|e| e.to_string())?; Ok(()) } diff --git a/src-tauri/src/tray_position.rs b/src-tauri/src/tray_position.rs new file mode 100644 index 0000000..8087ce5 --- /dev/null +++ b/src-tauri/src/tray_position.rs @@ -0,0 +1,102 @@ +#[derive(Debug, PartialEq)] +pub struct PopupRect { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +pub fn place( + anchor: (f64, f64), + origin: (i32, i32), + work: (u32, u32), + scale: f64, +) -> Option { + if work.0 == 0 + || work.1 == 0 + || !scale.is_finite() + || scale <= 0.0 + || !anchor.0.is_finite() + || !anchor.1.is_finite() + { + return None; + } + let width = (380.0 * scale).round().clamp(1.0, work.0 as f64); + let height = (410.0 * scale).round().clamp(1.0, work.1 as f64); + let left = origin.0 as f64; + let top = origin.1 as f64; + let right = left + work.0 as f64; + let bottom = top + work.1 as f64; + let gap = 4.0 * scale; + let below = anchor.1 + gap; + let y = if below + height <= bottom { + below + } else { + anchor.1 - height - gap + }; + Some(PopupRect { + x: (anchor.0 - width / 2.0).clamp(left, right - width).round() as i32, + y: y.clamp(top, bottom - height).round() as i32, + width: width as u32, + height: height as u32, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn bottom_taskbar_popup_stays_above_work_area_edge() { + let r = place((1900.0, 1060.0), (0, 0), (1920, 1040), 1.0).unwrap(); + assert_eq!( + r, + PopupRect { + x: 1540, + y: 630, + width: 380, + height: 410 + } + ); + } + #[test] + fn top_menu_bar_popup_stays_below_menu() { + let r = place((800.0, 12.0), (0, 24), (1440, 876), 1.0).unwrap(); + assert_eq!(r.y, 24); + } + #[test] + fn scaled_negative_origin_monitor_is_not_clamped_to_primary() { + let r = place((-20.0, 1400.0), (-2560, -100), (2560, 1480), 1.5).unwrap(); + assert_eq!(r.width, 570); + assert_eq!(r.height, 615); + assert_eq!(r.x, -570); + assert!(r.y >= -100 && r.y + r.height as i32 <= 1380); + } + #[test] + fn all_taskbar_edges_and_small_monitors_remain_inside_work_area() { + for scale in [1.0, 1.25, 1.5, 2.0, 3.0] { + for (origin, work) in [ + ((0, 0), (1920, 1040)), + ((40, 0), (1880, 1080)), + ((-1600, 40), (1600, 860)), + ((100, 100), (300, 240)), + ] { + for anchor in [ + (0.0, 500.0), + (1910.0, 500.0), + (1900.0, 1070.0), + (-1500.0, 0.0), + ] { + let r = place(anchor, origin, work, scale).unwrap(); + assert!(r.x >= origin.0 && r.y >= origin.1); + assert!(r.x as i64 + r.width as i64 <= origin.0 as i64 + work.0 as i64); + assert!(r.y as i64 + r.height as i64 <= origin.1 as i64 + work.1 as i64); + } + } + } + } + #[test] + fn invalid_monitor_metadata_fails_without_panicking() { + assert!(place((0.0, 0.0), (0, 0), (0, 10), 1.0).is_none()); + assert!(place((0.0, 0.0), (0, 0), (10, 10), f64::NAN).is_none()); + } +} diff --git a/src/components/TrayPopup.css b/src/components/TrayPopup.css index 2599086..834d373 100644 --- a/src/components/TrayPopup.css +++ b/src/components/TrayPopup.css @@ -19,7 +19,8 @@ body.is-tray-popup, padding: 16px 18px; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; color: #e8e0f0; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; user-select: none; display: flex; flex-direction: column; @@ -276,6 +277,7 @@ body.is-tray-popup, /* ===== 按钮栏 ===== */ .tp-actions { + flex-shrink: 0; display: flex; gap: 6px; margin-top: auto; diff --git a/src/components/TrayPopup.tsx b/src/components/TrayPopup.tsx index 3462925..e8199f6 100644 --- a/src/components/TrayPopup.tsx +++ b/src/components/TrayPopup.tsx @@ -1,7 +1,6 @@ import { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; -import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; // Rust 端 on_window_event(Focused(false)) 负责隐藏弹窗 import './TrayPopup.css'; @@ -161,7 +160,6 @@ export function TrayPopup() { const handleOpenDashboard = async () => { await invoke('show_main_window_cmd'); - getCurrentWebviewWindow().hide(); }; const q = data?.account?.cached_quota;