From 080f8e7969391b3eb9bb3ba746c47b13e1e5f401 Mon Sep 17 00:00:00 2001 From: YueMiyuki <76854136+YueMiyuki@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:29:11 +0800 Subject: [PATCH 1/5] feat(ui): Flyout tray panel --- src-tauri/Cargo.toml | 6 +- src-tauri/capabilities/default.json | 2 +- src-tauri/risuko-bt/src/torrent.rs | 13 +- src-tauri/src/commands/file_cmds.rs | 5 +- src-tauri/src/lib.rs | 12 + src-tauri/src/managers/flyout.rs | 171 ++++++++ src-tauri/src/managers/mod.rs | 1 + src-tauri/src/managers/tray.rs | 42 +- src-tauri/src/state.rs | 2 + src-tauri/tauri.conf.json | 1 + src/renderer/components/Flyout/Flyout.vue | 245 ++++++++++++ .../components/Flyout/FlyoutTaskItem.vue | 211 ++++++++++ src/renderer/pages/index/main.ts | 1 + src/renderer/pages/index/tray.html | 27 ++ src/renderer/pages/index/tray.ts | 133 +++++++ src/renderer/styles/flyout.css | 368 ++++++++++++++++++ src/shared/constants.ts | 4 +- src/shared/locales/en-US/app.ts | 3 + src/shared/locales/en-US/task.ts | 2 + src/shared/locales/zh-CN/app.ts | 3 + src/shared/locales/zh-CN/task.ts | 2 + src/shared/locales/zh-TW/app.ts | 3 + src/shared/locales/zh-TW/task.ts | 2 + vite.renderer.config.ts | 6 + 24 files changed, 1244 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/managers/flyout.rs create mode 100644 src/renderer/components/Flyout/Flyout.vue create mode 100644 src/renderer/components/Flyout/FlyoutTaskItem.vue create mode 100644 src/renderer/pages/index/tray.html create mode 100644 src/renderer/pages/index/tray.ts create mode 100644 src/renderer/styles/flyout.css diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 40653d29..5b805701 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,7 +42,11 @@ panic = "abort" [dependencies] risuko-engine = { workspace = true } -tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri = { version = "2", features = [ + "tray-icon", + "image-png", + "macos-private-api", +] } tauri-plugin-store = "2" tauri-plugin-deep-link = "2" tauri-plugin-dialog = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b72c1047..87cf1f90 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "identifier": "default", "description": "Default capabilities for Risuko", - "windows": ["main"], + "windows": ["main", "tray-panel"], "permissions": [ "core:default", "core:window:default", diff --git a/src-tauri/risuko-bt/src/torrent.rs b/src-tauri/risuko-bt/src/torrent.rs index 71a6b109..56838baf 100644 --- a/src-tauri/risuko-bt/src/torrent.rs +++ b/src-tauri/risuko-bt/src/torrent.rs @@ -1700,7 +1700,6 @@ fn maybe_clear_endgame(chunk_tracker: &mut ChunkTracker) { } /// Handle an inbound BEP-55 ut_holepunch message - fn handle_holepunch( hp: HolepunchMsg, from_addr: SocketAddr, @@ -1791,10 +1790,14 @@ fn try_initiate_holepunch( let Some(relay_hp) = relay.their_ut_holepunch_id else { return; // relay doesn't support holepunch }; - if relay.cmd_tx.try_send(PeerCommand::Send(Message::Extended { - ext_id: relay_hp, - payload: build_holepunch(holepunch_type::RENDEZVOUS, target, 0), - })).is_ok() { + if relay + .cmd_tx + .try_send(PeerCommand::Send(Message::Extended { + ext_id: relay_hp, + payload: build_holepunch(holepunch_type::RENDEZVOUS, target, 0), + })) + .is_ok() + { holepunch_attempted.insert(target); log::debug!( target: "diag", diff --git a/src-tauri/src/commands/file_cmds.rs b/src-tauri/src/commands/file_cmds.rs index 2d72376f..1f747e68 100644 --- a/src-tauri/src/commands/file_cmds.rs +++ b/src-tauri/src/commands/file_cmds.rs @@ -1204,7 +1204,10 @@ fn trash_generated_torrent_sidecars_in_dir(dir: &Path, normalized_info_hash: Opt // dict and comparing to this task's info-hash identifies the sidecar // precisely, and never touches an unrelated torrent (different hash). let hex_stem = generated_torrent_hex_stem(file_name); - let matched_by_name = hex_stem.as_deref().map(|stem| stem == hash).unwrap_or(false); + let matched_by_name = hex_stem + .as_deref() + .map(|stem| stem == hash) + .unwrap_or(false); // Only probe content when the filename has no hex-infohash stem. // A hex-named .torrent whose stem differs from our hash is definitively // a different torrent; content-matching it would be a false positive that diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 348fb2b5..1f1e0fde 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -338,6 +338,8 @@ pub fn run() { managers::tray::setup_tray(app)?; + managers::flyout::setup_flyout(app)?; + // Start RSS background polling if let Ok(guard) = app.state::().rss.lock() { if let Some(rss) = guard.clone() { @@ -353,6 +355,9 @@ pub fn run() { }) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if window.label() != "main" { + return; + } let quitting = window .app_handle() .state::() @@ -364,6 +369,13 @@ pub fn run() { api.prevent_close(); let _ = commands::app_cmds::hide_main_window(window.app_handle()); } + + if let tauri::WindowEvent::Focused(false) = event { + #[cfg(not(target_os = "android"))] + if window.label() == managers::flyout::FLYOUT_LABEL { + let _ = window.hide(); + } + } }) .invoke_handler(tauri::generate_handler![ commands::config_cmds::get_app_config, diff --git a/src-tauri/src/managers/flyout.rs b/src-tauri/src/managers/flyout.rs new file mode 100644 index 00000000..afd1a575 --- /dev/null +++ b/src-tauri/src/managers/flyout.rs @@ -0,0 +1,171 @@ +//! Tray "Quick Panel" flyout window + +#[cfg(not(target_os = "android"))] +use tauri::{ + AppHandle, Emitter, Manager, PhysicalPosition, WebviewUrl, WebviewWindow, WebviewWindowBuilder, +}; + +#[cfg(target_os = "android")] +use tauri::AppHandle; + +#[cfg(not(target_os = "android"))] +pub const FLYOUT_LABEL: &str = "tray-panel"; + +/// Logical size of the flyout window +#[cfg(not(target_os = "android"))] +const FLYOUT_WIDTH: f64 = 396.0; +#[cfg(not(target_os = "android"))] +const FLYOUT_HEIGHT: f64 = 540.0; + +/// Gap in physical pixels between the tray icon and the flyout edge +#[cfg(not(target_os = "android"))] +const FLYOUT_GAP: i32 = 4; + +/// Create the flyout window +#[cfg(not(target_os = "android"))] +pub fn setup_flyout(app: &tauri::App) -> Result<(), Box> { + let handle = app.handle(); + // Skip if it somehow already exists + if handle.get_webview_window(FLYOUT_LABEL).is_some() { + return Ok(()); + } + + let mut builder = + WebviewWindowBuilder::new(app, FLYOUT_LABEL, WebviewUrl::App("tray.html".into())) + .title("Risuko Quick Panel") + .inner_size(FLYOUT_WIDTH, FLYOUT_HEIGHT) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .visible(false) + .focused(false) + .transparent(true) + .shadow(false) + .accept_first_mouse(true); + + #[cfg(target_os = "macos")] + { + builder = builder.visible_on_all_workspaces(true); + } + + builder.build()?; + Ok(()) +} + +#[cfg(target_os = "android")] +pub fn setup_flyout(_app: &tauri::App) -> Result<(), Box> { + Ok(()) +} + +/// Cache the tray icon rect +#[cfg(not(target_os = "android"))] +pub fn cache_tray_rect(app: &AppHandle, rect: &tauri::Rect, scale_factor: f64) { + let pos = rect.position.to_physical::(scale_factor); + let size = rect.size.to_physical::(scale_factor); + if let Some(state) = app.try_state::() { + if let Ok(mut anchor) = state.tray_anchor.lock() { + *anchor = Some((pos.x, pos.y, size.width, size.height)); + } + } +} + +/// Toggle the flyout +#[cfg(not(target_os = "android"))] +pub fn toggle_flyout(app: &AppHandle) { + let Some(window) = app.get_webview_window(FLYOUT_LABEL) else { + log::warn!("[Risuko] flyout window not found"); + return; + }; + + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + return; + } + + position_flyout(app, &window); + let _ = window.show(); + let _ = window.set_focus(); + // Wake the webview's polling loop so the panel is fresh on open. + let _ = window.emit("flyout:show", ()); +} + +#[cfg(target_os = "android")] +pub fn toggle_flyout(_app: &AppHandle) {} + +/// Position the flyout near the cached tray anchor +#[cfg(not(target_os = "android"))] +fn position_flyout(app: &AppHandle, window: &WebviewWindow) { + // Resolve the anchor point (icon center) in physical pixels + let anchor = app + .try_state::() + .and_then(|state| state.tray_anchor.lock().ok().and_then(|guard| *guard)); + + let (icon_x, icon_y, icon_w, icon_h) = match anchor { + Some(rect) => rect, + None => { + // No tray rect was ever delivered + match app.cursor_position() { + Ok(pos) => (pos.x, pos.y, 0.0, 0.0), + Err(_) => (0.0, 0.0, 0.0, 0.0), + } + } + }; + + let icon_center_x = icon_x + icon_w / 2.0; + let icon_center_y = icon_y + icon_h / 2.0; + + // Find the monitor under the icon; fall back to primary + let monitor = app + .monitor_from_point(icon_center_x, icon_center_y) + .ok() + .flatten() + .or_else(|| app.primary_monitor().ok().flatten()); + + let Some(monitor) = monitor else { + // Last resort: drop it at the anchor with no clamping + let _ = window.set_position(PhysicalPosition::new(icon_x as i32, icon_y as i32)); + return; + }; + + let scale = monitor.scale_factor(); + let work = monitor.work_area(); + let wa_x = work.position.x as f64; + let wa_y = work.position.y as f64; + let wa_w = work.size.width as f64; + let wa_h = work.size.height as f64; + + // Window size in physical pixels + let win_w = FLYOUT_WIDTH * scale; + let win_h = FLYOUT_HEIGHT * scale; + let gap = FLYOUT_GAP as f64 * scale; + + // Decide above vs below using the icon center relative to the work area + let in_top_half = icon_center_y < wa_y + wa_h / 2.0; + let mut y = if in_top_half { + // Place below the icon + icon_y + icon_h + gap + } else { + // Place above the icon + icon_y - win_h - gap + }; + + // Horizontally center on the icon + let mut x = icon_center_x - win_w / 2.0; + + // Clamp into the work area + let max_x = wa_x + wa_w - win_w; + let max_y = wa_y + wa_h - win_h; + if x < wa_x { + x = wa_x; + } else if x > max_x { + x = max_x.max(wa_x); + } + if y < wa_y { + y = wa_y; + } else if y > max_y { + y = max_y.max(wa_y); + } + + let _ = window.set_position(PhysicalPosition::new(x.round() as i32, y.round() as i32)); +} diff --git a/src-tauri/src/managers/mod.rs b/src-tauri/src/managers/mod.rs index b9d2e6db..b38dd7aa 100644 --- a/src-tauri/src/managers/mod.rs +++ b/src-tauri/src/managers/mod.rs @@ -1,3 +1,4 @@ +pub mod flyout; pub mod menu; pub mod tray; pub mod vault; diff --git a/src-tauri/src/managers/tray.rs b/src-tauri/src/managers/tray.rs index a20ff9c2..0c61cafd 100644 --- a/src-tauri/src/managers/tray.rs +++ b/src-tauri/src/managers/tray.rs @@ -5,24 +5,24 @@ use tauri::{ image::Image, menu::{Menu, MenuBuilder, MenuItemBuilder, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - App, AppHandle, Emitter, Manager, + App, AppHandle, Emitter, }; #[cfg(target_os = "android")] use tauri::{App, AppHandle}; #[cfg(not(target_os = "android"))] -use super::{emit_command, show_and_emit}; +use super::{emit_command, flyout, show_and_emit}; #[cfg(not(target_os = "android"))] -fn toggle_main_window(app: &AppHandle) { - if let Some(window) = app.get_webview_window("main") { - let is_visible = window.is_visible().unwrap_or(false); - if is_visible { - let _ = crate::commands::app_cmds::hide_main_window(app); - } else { - let _ = crate::commands::app_cmds::show_main_window(app); - } +fn tray_event_rect(event: &TrayIconEvent) -> Option<(&tauri::Rect, (f64, f64))> { + match event { + TrayIconEvent::Click { rect, position, .. } + | TrayIconEvent::DoubleClick { rect, position, .. } + | TrayIconEvent::Enter { rect, position, .. } + | TrayIconEvent::Move { rect, position, .. } + | TrayIconEvent::Leave { rect, position, .. } => Some((rect, (position.x, position.y))), + _ => None, } } @@ -62,6 +62,11 @@ fn build_tray_menu( get_tray_menu_text(labels, "tray-show", "Show Risuko"), ) .build(handle)?; + let quick_panel = MenuItemBuilder::with_id( + "tray-quick-panel", + get_tray_menu_text(labels, "tray-quick-panel", "Quick Panel"), + ) + .build(handle)?; let manual = MenuItemBuilder::with_id( "tray-manual", get_tray_menu_text(labels, "tray-manual", "Manual"), @@ -95,6 +100,7 @@ fn build_tray_menu( &open_file, &sep1, &show, + &quick_panel, &manual, &check_updates, &sep2, @@ -123,13 +129,26 @@ pub fn setup_tray(app: &App) -> Result<(), Box> { .icon_as_template(true) .show_menu_on_left_click(false) .on_tray_icon_event(|tray, event| { + let app = tray.app_handle(); + // Cache the tray icon rect from any positional event so the flyout + // can anchor near the tray even when opened from the menu item + if let Some((rect, point)) = tray_event_rect(&event) { + let scale = app + .monitor_from_point(point.0, point.1) + .ok() + .flatten() + .map(|m| m.scale_factor()) + .unwrap_or(1.0); + flyout::cache_tray_rect(app, rect, scale); + } + if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { - toggle_main_window(tray.app_handle()); + flyout::toggle_flyout(app); } }) .on_menu_event(move |app, event| { @@ -141,6 +160,7 @@ pub fn setup_tray(app: &App) -> Result<(), Box> { "tray-show" => { let _ = crate::commands::app_cmds::show_main_window(app); } + "tray-quick-panel" => flyout::toggle_flyout(app), "tray-manual" => { let _ = open::that("https://github.com/YueMiyuki/Risuko/wiki"); } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index fddd9597..ddf30efd 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -16,6 +16,7 @@ pub struct AppState { pub upload_sinks: Mutex>>, pub vault: Arc, pub log_dir: PathBuf, + pub tray_anchor: Mutex>, /// Keeps the file logger alive; dropped when AppState is dropped. pub _log_guard: Option, } @@ -43,6 +44,7 @@ impl AppState { upload_sinks: Mutex::new(Some(Arc::new(upload_manager))), vault: Arc::new(VaultManager::new()), log_dir, + tray_anchor: Mutex::new(None), _log_guard: Some(log_guard), }) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d9b4ea0c..17a81264 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "beforeBuildCommand": "pnpm pack:renderer" }, "app": { + "macOSPrivateApi": true, "windows": [ { "label": "main", diff --git a/src/renderer/components/Flyout/Flyout.vue b/src/renderer/components/Flyout/Flyout.vue new file mode 100644 index 00000000..107da7ed --- /dev/null +++ b/src/renderer/components/Flyout/Flyout.vue @@ -0,0 +1,245 @@ + + + diff --git a/src/renderer/components/Flyout/FlyoutTaskItem.vue b/src/renderer/components/Flyout/FlyoutTaskItem.vue new file mode 100644 index 00000000..34a813c6 --- /dev/null +++ b/src/renderer/components/Flyout/FlyoutTaskItem.vue @@ -0,0 +1,211 @@ + + + diff --git a/src/renderer/pages/index/main.ts b/src/renderer/pages/index/main.ts index 703ba8d4..66799995 100644 --- a/src/renderer/pages/index/main.ts +++ b/src/renderer/pages/index/main.ts @@ -48,6 +48,7 @@ const updateTrayMenuLabels = (i18n: { t: (key: string) => string }) => { "tray-new-bt-task": i18n.t("task.new-bt-task"), "tray-open-file": i18n.t("task.open-file"), "tray-show": i18n.t("app.show"), + "tray-quick-panel": i18n.t("app.quick-panel"), "tray-manual": i18n.t("help.manual"), "tray-check-updates": i18n.t("app.check-for-updates"), "tray-task-list": i18n.t("app.task-list"), diff --git a/src/renderer/pages/index/tray.html b/src/renderer/pages/index/tray.html new file mode 100644 index 00000000..5b36bbaa --- /dev/null +++ b/src/renderer/pages/index/tray.html @@ -0,0 +1,27 @@ + + + + + + Risuko Quick Panel + + + + + + +
+ + + + diff --git a/src/renderer/pages/index/tray.ts b/src/renderer/pages/index/tray.ts new file mode 100644 index 00000000..6d13a157 --- /dev/null +++ b/src/renderer/pages/index/tray.ts @@ -0,0 +1,133 @@ +import { APP_THEME } from "@shared/constants"; +import { getLanguage } from "@shared/locales"; +import type { AppConfig } from "@shared/types/config"; +import logger from "@shared/utils/logger"; +import { listen } from "@tauri-apps/api/event"; +import { createApp } from "vue"; +import Flyout from "@/components/Flyout/Flyout.vue"; +import { getLocaleManager } from "@/components/Locale"; +import UiProgress from "@/components/ui/compat/UiProgress.vue"; +import store from "@/store"; +import { useAppStore } from "@/store/app"; +import { usePreferenceStore } from "@/store/preference"; +import { useTaskStore } from "@/store/task"; +import { getSystemTheme } from "@/utils/native"; + +import "@/styles/app.css"; +import "@/styles/flyout.css"; + +const POLL_INTERVAL = 1000; + +function applyTheme() { + const pref = usePreferenceStore(); + const appStore = useAppStore(); + const theme = pref.theme; + const effective = + theme === APP_THEME.AUTO ? appStore.systemTheme : theme || APP_THEME.LIGHT; + const className = effective === APP_THEME.DARK ? "dark" : ""; + document.documentElement.className = className; +} + +function startPolling() { + const appStore = useAppStore(); + const taskStore = useTaskStore(); + let timer: number | null = null; + + const tick = async () => { + if (document.hidden) { + return; + } + try { + await appStore.fetchGlobalStat(); + await taskStore.fetchList(); + } catch (err) { + logger.warn("[Risuko] flyout poll failed:", (err as Error).message); + } + }; + + const start = () => { + if (timer !== null) { + return; + } + void tick(); + timer = window.setInterval(tick, POLL_INTERVAL); + }; + + const stop = () => { + if (timer === null) { + return; + } + window.clearInterval(timer); + timer = null; + }; + + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + stop(); + } else { + start(); + } + }); + + if (!document.hidden) { + start(); + } + + return { tick, start }; +} + +async function init(config: AppConfig) { + const locale = getLanguage(config?.locale || "auto"); + const localeManager = getLocaleManager(); + await localeManager.changeLanguageByLocale(locale); + const i18n = localeManager.getI18n(); + + applyTheme(); + + const app = createApp(Flyout); + app.use(store); + app.component("ui-progress", UiProgress); + app.config.globalProperties.$t = ( + key: string, + value?: Record, + ) => i18n.t(key, value); + app.mount("#app"); + + const { tick } = startPolling(); + + const media = window.matchMedia?.("(prefers-color-scheme: dark)"); + media?.addEventListener?.("change", () => { + useAppStore().updateSystemTheme(getSystemTheme()); + applyTheme(); + }); + + await listen("flyout:show", async () => { + await usePreferenceStore().fetchPreference(); + applyTheme(); + void tick(); + }); + + await listen<{ command?: string }>("command", (event) => { + const command = event.payload?.command; + if ( + command === "application:update-theme" || + command === "application:update-system-theme" + ) { + void usePreferenceStore() + .fetchPreference() + .then(() => { + useAppStore().updateSystemTheme(getSystemTheme()); + applyTheme(); + }); + } + }); +} + +usePreferenceStore() + .fetchPreference() + .then((config) => { + init(config); + }) + .catch((err: unknown) => { + logger.warn("[Risuko] flyout init failed:", err); + }); diff --git a/src/renderer/styles/flyout.css b/src/renderer/styles/flyout.css new file mode 100644 index 00000000..af951f5a --- /dev/null +++ b/src/renderer/styles/flyout.css @@ -0,0 +1,368 @@ +/* For tray flyout */ + +html, +body, +#app { + background: transparent !important; +} + +#app { + height: 100vh; + width: 100vw; +} + +.flyout-root { + position: absolute; + inset: 10px; + display: flex; + flex-direction: column; + overflow: hidden; + background-color: var(--popover); + color: var(--popover-foreground); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: + 0 12px 32px -8px rgba(0, 0, 0, 0.32), + 0 4px 12px -4px rgba(0, 0, 0, 0.18); + font-size: 13px; + box-sizing: border-box; +} + +.flyout-root * { + box-sizing: border-box; +} + +.flyout-root :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + border-radius: 8px; +} + +.flyout-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 16px 10px; + flex-shrink: 0; + -webkit-user-select: none; + user-select: none; +} + +.flyout-title { + display: flex; + align-items: baseline; + gap: 7px; + min-width: 0; +} + +.flyout-title-text { + font-size: 15px; + font-weight: 600; + color: var(--foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.flyout-title-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 6px; + border-radius: 9px; + background-color: var(--muted); + color: var(--muted-foreground); + font-size: 11px; + font-weight: 600; + line-height: 1; +} + +.flyout-summary { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.flyout-summary-speed { + display: inline-flex; + align-items: center; + gap: 4px; + font-variant-numeric: tabular-nums; + font-size: 12px; + color: var(--muted-foreground); +} + +.flyout-summary-speed :first-child { + color: var(--primary); +} + +.flyout-tabs { + display: flex; + gap: 4px; + padding: 4px 12px 10px; + flex-shrink: 0; +} + +.flyout-tab { + position: relative; + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + height: 34px; + border: none; + border-radius: 9px; + background: var(--muted); + color: var(--muted-foreground); + cursor: pointer; + transition: + background-color 0.14s ease, + color 0.14s ease; +} + +.flyout-tab:hover { + background-color: var(--accent); + color: var(--accent-foreground); +} + +.flyout-tab.active { + background-color: var(--primary); + color: var(--primary-foreground); +} + +.flyout-tab-badge { + position: absolute; + top: -5px; + right: -3px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background-color: var(--primary); + color: var(--primary-foreground); + font-size: 10px; + font-weight: 600; + line-height: 1; + border: 2px solid var(--popover); +} + +.flyout-tab.active .flyout-tab-badge { + background-color: var(--popover); + color: var(--primary); +} + +.flyout-list { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 4px 8px 8px; + display: flex; + flex-direction: column; + gap: 3px; + border-top: 1px solid var(--border); +} + +.flyout-list::-webkit-scrollbar { + width: 8px; +} + +.flyout-list::-webkit-scrollbar-thumb { + background-color: var(--border); + border-radius: 4px; +} + +.flyout-list::-webkit-scrollbar-thumb:hover { + background-color: var(--muted-foreground); +} + +.flyout-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--muted-foreground); + font-size: 12px; +} + +.flyout-task-item { + display: flex; + align-items: center; + gap: 11px; + padding: 9px 10px; + border-radius: 10px; + transition: background-color 0.12s ease; +} + +.flyout-task-item:hover, +.flyout-task-item:focus-within { + background-color: var(--accent); +} + +.flyout-task-icon { + flex-shrink: 0; + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 9px; + background-color: var(--muted); + color: var(--muted-foreground); +} + +.flyout-task-icon.status-active { + color: #619eff; + background-color: rgba(97, 158, 255, 0.14); +} + +.flyout-task-icon.status-complete { + color: #22c55e; + background-color: rgba(34, 197, 94, 0.14); +} + +.flyout-task-icon.status-error { + color: #ef4444; + background-color: rgba(239, 68, 68, 0.14); +} + +.flyout-task-icon.status-seeding { + color: #a78bfa; + background-color: rgba(167, 139, 250, 0.14); +} + +.flyout-task-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 5px; +} + +.flyout-task-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.flyout-task-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: var(--foreground); +} + +.flyout-task-pct { + flex-shrink: 0; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--muted-foreground); +} + +.flyout-task-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 11px; + color: var(--muted-foreground); +} + +.flyout-task-speed { + display: inline-flex; + align-items: center; + gap: 3px; + font-variant-numeric: tabular-nums; +} + +.flyout-task-sizes { + margin-left: auto; + font-variant-numeric: tabular-nums; +} + +.flyout-task-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.12s ease; +} + +.flyout-task-item:hover .flyout-task-actions, +.flyout-task-item:focus-within .flyout-task-actions { + opacity: 1; +} + +.flyout-task-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + transition: + background-color 0.12s ease, + color 0.12s ease; +} + +.flyout-task-action:hover { + background-color: var(--muted); + color: var(--foreground); +} + +.flyout-footer { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +.flyout-footer-spacer { + flex: 1; +} + +.flyout-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + transition: + background-color 0.12s ease, + color 0.12s ease; +} + +.flyout-action:hover { + background-color: var(--accent); + color: var(--accent-foreground); +} + +.flyout-action-danger:hover { + background-color: rgba(239, 68, 68, 0.12); + color: #ef4444; +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 51c58653..535ac926 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -230,11 +230,11 @@ export const NONE_SELECTED_FILES = "none"; export const SELECTED_ALL_FILES = "all"; export const TRAY_CANVAS_CONFIG = { - WIDTH: 66, + WIDTH: 56, HEIGHT: 16, ICON_WIDTH: 16, ICON_HEIGHT: 16, - TEXT_WIDTH: 46, + TEXT_WIDTH: 42, TEXT_FONT_SIZE: 8, }; diff --git a/src/shared/locales/en-US/app.ts b/src/shared/locales/en-US/app.ts index 7e7f9292..f0019fc4 100644 --- a/src/shared/locales/en-US/app.ts +++ b/src/shared/locales/en-US/app.ts @@ -28,6 +28,9 @@ export default { "hide-others": "Hide Others", unhide: "Show All", show: "Show Risuko", + "quick-panel": "Quick Panel", + "flyout-active-count": "{{count}} active", + "flyout-empty": "No tasks here", quit: "Quit Risuko", "quit-confirm": "Are you sure you want to quit Risuko?", "quit-confirm-active": diff --git a/src/shared/locales/en-US/task.ts b/src/shared/locales/en-US/task.ts index 9f41eb8e..ca0360a5 100644 --- a/src/shared/locales/en-US/task.ts +++ b/src/shared/locales/en-US/task.ts @@ -152,6 +152,8 @@ export default { "pause-task-success": 'Successfully paused task "{{taskName}}"', "pause-task-fail": 'Failed to pause task "{{taskName}}"', "resume-task": "Resume Task", + "stop-seeding": "Stop Seeding", + "open-task": "Open File", "resume-task-success": 'Successfully resumed task "{{taskName}}"', "resume-task-fail": 'Failed to resume task "{{taskName}}"', "delete-task": "Delete Task", diff --git a/src/shared/locales/zh-CN/app.ts b/src/shared/locales/zh-CN/app.ts index 512151ab..bd2e26b4 100644 --- a/src/shared/locales/zh-CN/app.ts +++ b/src/shared/locales/zh-CN/app.ts @@ -27,6 +27,9 @@ export default { "hide-others": "隐藏其他", unhide: "显示全部", show: "显示 Risuko", + "quick-panel": "快捷面板", + "flyout-active-count": "{{count}} 个进行中", + "flyout-empty": "暂无任务", quit: "退出 Risuko", "quit-confirm": "确定要退出 Risuko 吗?", "quit-confirm-active": "还有 {{count}} 个下载任务正在进行中,确定要退出吗?", diff --git a/src/shared/locales/zh-CN/task.ts b/src/shared/locales/zh-CN/task.ts index 18ea2ba4..3b8f7b9f 100644 --- a/src/shared/locales/zh-CN/task.ts +++ b/src/shared/locales/zh-CN/task.ts @@ -135,6 +135,8 @@ export default { "pause-task-success": '暂停任务 "{{taskName}}" 成功', "pause-task-fail": '暂停任务 "{{taskName}}" 失败', "resume-task": "恢复任务", + "stop-seeding": "停止做种", + "open-task": "打开文件", "resume-task-success": '恢复任务 "{{taskName}}" 成功', "resume-task-fail": '恢复任务 "{{taskName}}" 失败', "delete-task": "移除任务", diff --git a/src/shared/locales/zh-TW/app.ts b/src/shared/locales/zh-TW/app.ts index 2f1be198..c39ce0df 100644 --- a/src/shared/locales/zh-TW/app.ts +++ b/src/shared/locales/zh-TW/app.ts @@ -27,6 +27,9 @@ export default { "hide-others": "隱藏其它", unhide: "顯示全部", show: "顯示 Risuko", + "quick-panel": "快捷面板", + "flyout-active-count": "{{count}} 個進行中", + "flyout-empty": "暫無任務", quit: "結束 Risuko", "quit-confirm": "確定要結束 Risuko 嗎?", "quit-confirm-active": "還有 {{count}} 個下載任務正在進行中,確定要結束嗎?", diff --git a/src/shared/locales/zh-TW/task.ts b/src/shared/locales/zh-TW/task.ts index d5601848..991ce823 100644 --- a/src/shared/locales/zh-TW/task.ts +++ b/src/shared/locales/zh-TW/task.ts @@ -135,6 +135,8 @@ export default { "pause-task-success": '暫停任務 "{{taskName}}" 成功', "pause-task-fail": '暫停任務 "{{taskName}}" 失敗', "resume-task": "繼續任務", + "stop-seeding": "停止做種", + "open-task": "開啟檔案", "resume-task-success": '繼續任務 "{{taskName}}" 成功', "resume-task-fail": '繼續任務 "{{taskName}}" 失敗', "delete-task": "移除任務", diff --git a/vite.renderer.config.ts b/vite.renderer.config.ts index debb1d69..e0ed6660 100644 --- a/vite.renderer.config.ts +++ b/vite.renderer.config.ts @@ -58,6 +58,12 @@ export default defineConfig({ sourcemap: false, chunkSizeWarningLimit: 2000, minify: "terser", + rollupOptions: { + input: { + index: path.resolve(rootDir, "index.html"), + tray: path.resolve(rootDir, "tray.html"), + }, + }, }, define: { "process.env.NODE_ENV": JSON.stringify( From 3cb11a94dff3bcb0d017f5f41b1b2f6d727e885e Mon Sep 17 00:00:00 2001 From: YueMiyuki <76854136+YueMiyuki@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:54:46 +0800 Subject: [PATCH 2/5] feat(ui): refine flyout UI - consistent border-radius, no menu bar gap, fix macOS activation on hide --- src-tauri/capabilities/tray-panel.json | 13 ++ src-tauri/src/lib.rs | 10 ++ src-tauri/src/managers/flyout.rs | 4 +- src/renderer/components/Flyout/Flyout.vue | 169 +++++++++++++++--- .../components/Flyout/FlyoutTaskItem.vue | 1 + src/renderer/pages/index/tray.html | 23 ++- src/renderer/pages/index/tray.ts | 29 ++- src/renderer/styles/flyout.css | 130 +++++++++++++- src/shared/locales/en-US/app.ts | 2 + src/shared/locales/zh-CN/app.ts | 2 + src/shared/locales/zh-TW/app.ts | 2 + 11 files changed, 338 insertions(+), 47 deletions(-) create mode 100644 src-tauri/capabilities/tray-panel.json diff --git a/src-tauri/capabilities/tray-panel.json b/src-tauri/capabilities/tray-panel.json new file mode 100644 index 00000000..9a1906d3 --- /dev/null +++ b/src-tauri/capabilities/tray-panel.json @@ -0,0 +1,13 @@ +{ + "identifier": "tray-panel", + "description": "Minimal capabilities for the tray flyout panel. Grants only window management, store access, and the invoke channel needed for flyout commands (show_window, get_global_stat, tell_active, tell_waiting, tell_stopped, pause/unpause/remove task, add_uri, open_path, reveal_in_folder). No filesystem, shell, process, OS, or dialog access.", + "windows": ["tray-panel"], + "permissions": [ + "core:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-start-dragging", + "store:default" + ] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f1e0fde..35a3bee5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -355,6 +355,12 @@ pub fn run() { }) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { + #[cfg(not(target_os = "android"))] + if window.label() == managers::flyout::FLYOUT_LABEL { + api.prevent_close(); + let _ = window.hide(); + return; + } if window.label() != "main" { return; } @@ -373,6 +379,10 @@ pub fn run() { if let tauri::WindowEvent::Focused(false) = event { #[cfg(not(target_os = "android"))] if window.label() == managers::flyout::FLYOUT_LABEL { + #[cfg(target_os = "macos")] + let _ = window + .app_handle() + .set_activation_policy(tauri::ActivationPolicy::Accessory); let _ = window.hide(); } } diff --git a/src-tauri/src/managers/flyout.rs b/src-tauri/src/managers/flyout.rs index afd1a575..504b34f2 100644 --- a/src-tauri/src/managers/flyout.rs +++ b/src-tauri/src/managers/flyout.rs @@ -19,7 +19,7 @@ const FLYOUT_HEIGHT: f64 = 540.0; /// Gap in physical pixels between the tray icon and the flyout edge #[cfg(not(target_os = "android"))] -const FLYOUT_GAP: i32 = 4; +const FLYOUT_GAP: i32 = 0; /// Create the flyout window #[cfg(not(target_os = "android"))] @@ -79,6 +79,8 @@ pub fn toggle_flyout(app: &AppHandle) { }; if window.is_visible().unwrap_or(false) { + #[cfg(target_os = "macos")] + let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory); let _ = window.hide(); return; } diff --git a/src/renderer/components/Flyout/Flyout.vue b/src/renderer/components/Flyout/Flyout.vue index 107da7ed..3d0404fe 100644 --- a/src/renderer/components/Flyout/Flyout.vue +++ b/src/renderer/components/Flyout/Flyout.vue @@ -5,7 +5,7 @@ {{ activeTabLabel }} {{ activeTabCount }} -
+