Files
qiming/qimingclaw/specs/agent-client/vendorsdoc/tray-icon.md

14 KiB
Raw Permalink Blame History

tray-icon

项目概述

跨平台系统托盘图标库,基于 muda 库实现,支持 Windows、macOS 和 Linux。

GitHub: https://github.com/tauri-apps/tray-icon.git 本地路径: vendors/tray-icon

目录结构

tray-icon/
├── src/
│   ├── lib.rs              # 公共 API
│   ├── icon.rs             # 图标处理
│   ├── menu.rs             # 菜单构建
│   └── platform_impl/      # 平台特定实现
│       ├── mod.rs
│       ├── windows.rs
│       ├── macos.rs
│       └── linux.rs
├── Cargo.toml
└── README.md

核心依赖

[dependencies]
muda = "1.0"              # 跨平台菜单库
crossbeam-channel = "0.5"
once_cell = "1.18"
thiserror = "1.0"

# 平台依赖
objc2 = { version = "0.5", optional = true }
gtk = { version = "0.18", optional = true, features = ["v3_22"] }
libappindicator = { version = "0.8", optional = true }
windows = { version = "0.48", optional = true }

核心 API

TrayIconBuilder

// lib.rs

pub struct TrayIconBuilder {
    id: TrayIconId,
    attrs: TrayIconAttributes,
}

impl TrayIconBuilder {
    pub fn new() -> Self;

    /// 设置托盘图标
    pub fn with_icon(mut self, icon: Icon) -> Self;

    /// 设置提示文本
    pub fn with_tooltip<S: AsRef<str>>(self, s: S) -> Self;

    /// 设置标题macOS
    pub fn with_title<S: AsRef<str>>(self, title: S) -> Self;

    /// 设置上下文菜单
    pub fn with_menu(mut self, menu: Box<dyn menu::ContextMenu>) -> Self;

    /// 左键点击显示菜单(默认右键)
    pub fn with_menu_on_left_click(self, enable: bool) -> Self;

    /// 设置临时目录路径
    pub fn with_temp_dir_path<P: AsRef<Path>>(self, s: P) -> Self;

    /// 构建托盘图标
    pub fn build(self) -> Result<TrayIcon>;
}

TrayIcon

pub struct TrayIcon {
    id: TrayIconId,
    tray: Rc<RefCell<platform_impl::TrayIcon>>,
}

impl TrayIcon {
    /// 设置图标
    pub fn set_icon(&self, icon: Option<Icon>) -> Result<()>;

    /// 设置菜单
    pub fn set_menu(&self, menu: Option<Box<dyn menu::ContextMenu>>);

    /// 设置提示文本
    pub fn set_tooltip<S: AsRef<str>>(&self, tooltip: Option<S>) -> Result<()>;

    /// 设置可见性
    pub fn set_visible(&self, visible: bool) -> Result<()>;

    /// 获取托盘区域矩形
    pub fn rect(&self) -> Option<Rect>;
}

TrayIconEvent

pub enum TrayIconEvent {
    Click {
        id: TrayIconId,
        position: Position,
        rect: Rect,
        button: MouseButton,
        button_state: ButtonState,
    },
    DoubleClick {
        id: TrayIconId,
        position: Position,
        rect: Rect,
        button: MouseButton,
    },
    Enter {
        id: TrayIconId,
        position: Position,
        rect: Rect,
    },
    Move {
        id: TrayIconId,
        position: Position,
        rect: Rect,
    },
    Leave {
        id: TrayIconId,
        position: Position,
        rect: Rect,
    },
}

impl TrayIconEvent {
    /// 获取事件接收器
    pub fn receiver() -> &'a TrayIconEventReceiver;

    /// 设置事件处理函数
    pub fn set_event_handler<F>(f: Option<F>)
    where
        F: Fn(TrayIconEvent, &dyn TrayIconHandle) + Send + 'static;
}

使用示例

use tray_icon::{TrayIconBuilder, icon::Icon, menu::MenuBuilder};

fn main() -> Result<()> {
    // 加载图标
    let icon = Icon::from_resource(1, None);

    // 创建菜单
    let menu = MenuBuilder::new()
        .item("Show Window", "show_window", true, None)?
        .separator()
        .item("Settings", "settings", true, None)?
        .separator()
        .quit("Quit")
        .build()?;

    // 创建托盘图标
    let _tray_icon = TrayIconBuilder::new()
        .with_icon(icon)
        .with_tooltip("My App")
        .with_menu(Box::new(menu))
        .with_on_tray_event(|event, tray| {
            match event {
                TrayIconEvent::Click { button, .. } => {
                    if button == MouseButton::Left {
                        show_window();
                    }
                }
                TrayIconEvent::MenuItemClick { id, .. } => {
                    match id.as_ref() {
                        "quit" => std::process::exit(0),
                        "settings" => open_settings(),
                        "show_window" => show_window(),
                        _ => {}
                    }
                }
                _ => {}
            }
        })
        .build()?;

    Ok(())
}

与 agent-client 集成场景

场景1完整的托盘管理器

// agent-client 托盘管理器

use tray_icon::{TrayIconBuilder, icon::Icon, menu::{Menu, MenuItem, MenuBuilder}};
use std::sync::atomic::{AtomicBool, Ordering};

pub struct TrayManager {
    // 托盘图标
    tray_icon: Option<TrayIcon>,
    // 是否显示主窗口
    window_visible: AtomicBool,
    // 连接状态
    connection_state: Arc<dyn ConnectionStatusProvider>,
    // 应用退出标志
    exit_flag: Arc<AtomicBool>,
}

impl TrayManager {
    pub fn new(
        connection_state: Arc<dyn ConnectionStatusProvider>,
        exit_flag: Arc<AtomicBool>,
    ) -> Result<Self> {
        let icon = Self::load_icon()?;

        let menu = MenuBuilder::new()
            .item("Show Window", "show_window", true, None)?
            .separator()
            .item("Agent Status", "status", true, None)?
            .item("Open Settings", "settings", true, None)?
            .separator()
            .item("Quit", "quit", true, None)?
            .build()?;

        let tray_icon = TrayIconBuilder::new()
            .with_icon(icon)
            .with_tooltip("qiming-agent")
            .with_menu(Box::new(menu))
            .with_menu_on_left_click(true)  // 左键也显示菜单
            .build()?;

        Ok(Self {
            tray_icon: Some(tray_icon),
            window_visible: AtomicBool::new(true),
            connection_state,
            exit_flag,
        })
    }

    fn load_icon() -> Result<Icon> {
        // 从资源文件加载图标
        let icon_data = include_bytes!("../../assets/tray_icon.png");
        let image = image::load_from_memory(icon_data)?
            .to_rgba8()
            .into_raw();

        Icon::from_rgba(icon_data.to_vec(), 32, 32)
    }

    /// 设置托盘事件处理
    pub fn setup_event_handler(&self) {
        TrayIconEvent::set_event_handler(Some(move |event, _tray| {
            match event {
                TrayIconEvent::Click { button, .. } => {
                    if button == MouseButton::Left {
                        // 左键点击切换窗口可见性
                        // 发送事件到主窗口
                    }
                }
                TrayIconEvent::MenuItemClick { id, .. } => {
                    match id.as_ref() {
                        "show_window" => {
                            // 显示主窗口
                        }
                        "settings" => {
                            // 打开设置窗口
                        }
                        "quit" => {
                            // 退出应用
                        }
                        _ => {}
                    }
                }
                _ => {}
            }
        }));
    }

    /// 更新托盘图标(根据连接状态)
    pub fn update_icon(&self, status: &ConnectionState) {
        let icon = match status {
            ConnectionState::Connected { .. } => self.load_connected_icon(),
            ConnectionState::Connecting => self.load_loading_icon(),
            ConnectionState::Disconnected { .. } => self.load_disconnected_icon(),
            ConnectionState::Error { .. } => self.load_error_icon(),
        };

        if let Some(tray) = &self.tray_icon {
            tray.set_icon(Some(icon)).ok();
        }
    }

    /// 更新托盘提示文本
    pub fn update_tooltip(&self, text: &str) {
        if let Some(tray) = &self.tray_icon {
            tray.set_tooltip(Some(text)).ok();
        }
    }

    /// 销毁托盘
    pub fn destroy(&mut self) {
        if let Some(tray) = self.tray_icon.take() {
            tray.set_visible(false).ok();
        }
    }
}

场景2托盘菜单与主窗口通信

// 使用 channel 进行托盘事件通信

use tokio::sync::mpsc;

pub enum TrayEvent {
    ShowWindow,
    OpenSettings,
    Quit,
}

pub struct TrayEventChannel {
    sender: mpsc::UnboundedSender<TrayEvent>,
    receiver: Arc<Mutex<mpsc::UnboundedReceiver<TrayEvent>>>,
}

impl TrayEventChannel {
    pub fn new() -> Self {
        let (sender, receiver) = mpsc::unbounded_channel();
        Self {
            sender,
            receiver: Arc::new(Mutex::new(receiver)),
        }
    }

    pub fn sender(&self) -> mpsc::UnboundedSender<TrayEvent> {
        self.sender.clone()
    }

    pub fn receiver(&self) -> Arc<Mutex<mpsc::UnboundedReceiver<TrayEvent>>> {
        self.receiver.clone()
    }
}

/// 在托盘事件中发送消息
fn setup_tray_events(channel: &TrayEventChannel) {
    TrayIconEvent::set_event_handler(Some(move |event, _tray| {
        if let TrayIconEvent::MenuItemClick { id, .. } = event {
            let event = match id.as_ref() {
                "show_window" => TrayEvent::ShowWindow,
                "settings" => TrayEvent::OpenSettings,
                "quit" => TrayEvent::Quit,
                _ => return,
            };
            channel.sender().send(event).ok();
        }
    }));
}

/// 在主窗口中监听托盘事件
async fn handle_tray_events(channel: &TrayEventChannel, window: &WindowHandle) {
    let mut receiver = channel.receiver().lock().await;
    while let Some(event) = receiver.recv().await {
        match event {
            TrayEvent::ShowWindow => {
                window.activate().ok();
            }
            TrayEvent::OpenSettings => {
                // 打开设置窗口
            }
            TrayEvent::Quit => {
                // 退出应用
                std::process::exit(0);
            }
        }
    }
}

场景3动态托盘菜单

// 根据状态动态更新菜单

pub struct DynamicTrayMenu {
    menu: Menu,
    status_item: MenuItem,
    settings_item: MenuItem,
}

impl DynamicTrayMenu {
    pub fn new() -> Result<Self> {
        let menu = MenuBuilder::new()
            .item("Show Window", "show_window", true, None)?
            .separator()
            .item("Status: Disconnected", "status", true, None)?
            .item("Open Settings", "settings", true, None)?
            .separator()
            .item("Quit", "quit", true, None)?
            .build()?;

        // 获取子菜单项的引用
        let status_item = menu.items().find(|item| item.id() == "status").unwrap();
        let settings_item = menu.items().find(|item| item.id() == "settings").unwrap();

        Ok(Self {
            menu,
            status_item,
            settings_item,
        })
    }

    /// 更新状态文本
    pub fn update_status(&mut self, status: &ConnectionState) {
        let text = match status {
            ConnectionState::Connected { mode, latency } => {
                match mode {
                    ConnectionMode::P2P => format!("Status: Connected (P2P) - {}ms", latency),
                    ConnectionMode::Relay => format!("Status: Connected (Relay) - {}ms", latency),
                }
            }
            ConnectionState::Connecting => "Status: Connecting...".into(),
            ConnectionState::Disconnected { .. } => "Status: Disconnected".into(),
            ConnectionState::Error { .. } => "Status: Error".into(),
        };

        // 更新菜单项文本(如果支持)
        // self.status_item.set_text(Some(&text));
    }

    /// 根据连接状态启用/禁用菜单项
    pub fn update_items(&mut self, connected: bool) {
        // 如果已连接,可能禁用某些选项
        // 如果未连接,可能启用重连选项
    }
}

场景4托盘图标动画

// 连接状态动画图标

pub struct AnimatedTrayIcon {
    frames: Vec<Icon>,
    current_frame: usize,
    timer: Option<JoinHandle<()>>,
}

impl AnimatedTrayIcon {
    pub fn new_loading_animation() -> Result<Self> {
        // 创建加载动画帧
        let frames = (0..8).map(|i| {
            let degrees = i * 45;
            Self::create_rotated_icon(degrees)
        }).collect::<Result<Vec<_>>>()?;

        Ok(Self {
            frames,
            current_frame: 0,
            timer: None,
        })
    }

    /// 开始动画
    pub fn start_animation(&mut self, tray: &TrayIcon) {
        let tray = tray.clone();
        self.timer = Some(tokio::spawn(async move {
            let mut interval = tokio::time::interval(Duration::from_millis(100));
            loop {
                interval.tick().await;
                // 下一帧
            }
        }));
    }

    /// 停止动画
    pub fn stop_animation(&mut self) {
        if let Some(timer) = self.timer.take() {
            timer.abort();
        }
    }

    fn create_rotated_icon(degrees: u32) -> Result<Icon> {
        // 创建旋转后的图标
        // 使用图像处理库生成旋转帧
        Ok(Icon::from_rgba(vec![], 32, 32).unwrap())
    }
}

在本项目中的使用

用于实现 agent-client 的任务栏常驻和托盘菜单功能:

agent-client
    │
    ├── tray-icon (托盘图标)
    │       │
    │       ├── 托盘图标显示 (根据连接状态变化)
    │       ├── 右键菜单 (显示窗口、设置、退出)
    │       ├── 左键点击 (切换窗口可见性)
    │       └── 动态提示文本 (显示连接状态)
    │
    └── muda (菜单系统)
            │
            ├── Show Window
            ├── Agent Status (只读)
            ├── Open Settings
            └── Quit