Agent skill
tray-icon
Cross-platform system tray and menu bar integration
Install this agent skill to your Project
npx add-skill https://github.com/johnlindquist/script-kit-next/tree/main/.opencode/skill/tray-icon
SKILL.md
tray-icon
Cross-platform system tray library from the Tauri ecosystem. Used in script-kit-gpui for macOS menu bar integration with a dropdown context menu.
Crate: tray-icon (v0.21.x)
Menu System: muda (re-exported as tray_icon::menu)
Key Types
Core Types (tray_icon)
| Type | Purpose |
|---|---|
TrayIcon |
The tray icon instance. Must stay alive to keep icon visible. |
TrayIconBuilder |
Builder pattern for creating tray icons |
Icon |
RGBA pixel data for the tray icon |
TrayIconEvent |
Click events on the tray icon itself |
TrayIconEventReceiver |
Channel receiver for tray events |
Menu Types (tray_icon::menu = muda)
| Type | Purpose |
|---|---|
Menu |
Root menu (use with window menu bars) |
Submenu |
Submenu or context menu root (preferred for tray) |
MenuItem |
Basic text menu item |
IconMenuItem |
Menu item with icon |
CheckMenuItem |
Menu item with checkmark toggle |
PredefinedMenuItem |
OS-native items (separator, copy, paste, about, quit) |
MenuEvent |
Emitted when any menu item is clicked |
MenuEventReceiver |
Channel receiver for menu events |
MenuId |
Unique identifier for menu items |
ContextMenu |
Trait for types that can be shown as context menus |
Usage in script-kit-gpui
File Structure
src/tray.rs # TrayManager, SVG rendering, menu creation
src/main.rs # Event loop integration
TrayManager Pattern
use tray_icon::{
menu::{
CheckMenuItem, ContextMenu, Icon as MenuIcon, IconMenuItem,
MenuEvent, MenuEventReceiver, MenuItem, PredefinedMenuItem, Submenu,
},
Icon, TrayIcon, TrayIconBuilder,
};
pub struct TrayManager {
tray_icon: TrayIcon, // Must stay alive!
launch_at_login_item: CheckMenuItem, // Store for later updates
}
impl TrayManager {
pub fn new() -> Result<Self> {
let icon = Self::create_icon_from_svg()?;
let (menu, launch_at_login_item) = Self::create_menu()?;
let mut builder = TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("Script Kit")
.with_menu(menu);
// macOS: Template images adapt to light/dark menu bar
#[cfg(target_os = "macos")]
{
builder = builder.with_icon_as_template(true);
}
let tray_icon = builder.build()?;
Ok(Self { tray_icon, launch_at_login_item })
}
}
Menu Event Receiver Pattern
impl TrayManager {
pub fn menu_event_receiver(&self) -> &MenuEventReceiver {
MenuEvent::receiver() // Global receiver
}
}
Menu Building
Use Submenu as Context Menu Root
On macOS, Menu::append only allows Submenu, but Submenu::append allows any item type. Use Submenu as your root for tray context menus:
fn create_menu() -> Result<(Box<dyn ContextMenu>, CheckMenuItem)> {
// Submenu works cross-platform as context menu root
let menu = Submenu::with_id("tray.root", "Script Kit", true);
// Regular menu items
let open_item = MenuItem::with_id("open", "Open App", true, None);
menu.append(&open_item)?;
// Separator
menu.append(&PredefinedMenuItem::separator())?;
// Icon menu items
let icon = MenuIcon::from_rgba(rgba_data, 32, 32)?;
let github_item = IconMenuItem::with_id(
"github",
"Open on GitHub",
true, // enabled
Some(icon),
None, // no accelerator
);
menu.append(&github_item)?;
// Check menu item (toggle state)
let launch_item = CheckMenuItem::with_id(
"launch_at_login",
"Launch at Login",
true, // enabled
false, // initial checked state
None, // no accelerator
);
menu.append(&launch_item)?;
// Return as ContextMenu trait object
Ok((Box::new(menu), launch_item))
}
Menu Item ID Pattern
Use string IDs for type-safe event matching:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrayMenuAction {
OpenApp,
Settings,
Quit,
}
impl TrayMenuAction {
pub const fn id(self) -> &'static str {
match self {
Self::OpenApp => "tray.open_app",
Self::Settings => "tray.settings",
Self::Quit => "tray.quit",
}
}
pub fn from_id(id: &str) -> Option<Self> {
match id {
"tray.open_app" => Some(Self::OpenApp),
"tray.settings" => Some(Self::Settings),
"tray.quit" => Some(Self::Quit),
_ => None,
}
}
}
// Usage in menu creation:
let item = MenuItem::with_id(TrayMenuAction::OpenApp.id(), "Open", true, None);
// Usage in event handling:
pub fn action_from_event(event: &MenuEvent) -> Option<TrayMenuAction> {
TrayMenuAction::from_id(&event.id.0)
}
Icon Handling
SVG to RGBA with usvg + resvg + tiny-skia
tray-icon requires RGBA pixel data. script-kit-gpui uses SVG rendering:
use usvg;
use tiny_skia;
use resvg;
fn render_svg_to_rgba(svg: &str, width: u32, height: u32) -> Result<Vec<u8>> {
// Parse SVG
let opts = usvg::Options::default();
let tree = usvg::Tree::from_str(svg, &opts)?;
// Create pixmap
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
// Calculate scale to fit
let size = tree.size();
let scale_x = width as f32 / size.width();
let scale_y = height as f32 / size.height();
let scale = scale_x.min(scale_y);
let transform = tiny_skia::Transform::from_scale(scale, scale);
// Render
resvg::render(&tree, transform, &mut pixmap.as_mut());
// Validate (catch silent rendering failures)
let rgba = pixmap.take();
let has_visible = rgba.chunks_exact(4).any(|px| px[3] != 0);
if !has_visible {
bail!("SVG rendered to fully transparent image");
}
Ok(rgba)
}
// Create tray icon from RGBA
fn create_icon_from_svg() -> Result<Icon> {
let rgba = render_svg_to_rgba(LOGO_SVG, 32, 32)?;
Icon::from_rgba(rgba, 32, 32)
}
// Create menu icon from RGBA
fn create_menu_icon_from_svg(svg: &str) -> Option<MenuIcon> {
render_svg_to_rgba(svg, 32, 32)
.ok()
.and_then(|rgba| MenuIcon::from_rgba(rgba, 32, 32).ok())
}
Icon Sizes
- Tray icon: 32x32 (Retina: 64x64 or use template)
- Menu icons: 16x16 or 32x32 for Retina
- macOS template: Use monochrome SVGs with
with_icon_as_template(true)
Event Handling
Polling Pattern (script-kit-gpui style)
// In main.rs - spawn async task for tray events
cx.spawn(async move |cx: &mut AsyncApp| {
loop {
Timer::after(Duration::from_millis(100)).await;
// Check for menu events
if let Ok(event) = tray_mgr.menu_event_receiver().try_recv() {
let action = TrayManager::action_from_event(&event);
match action {
Some(TrayMenuAction::OpenApp) => {
let _ = cx.update(|cx| {
show_main_window(cx);
});
}
Some(TrayMenuAction::Quit) => {
let _ = cx.update(|cx| {
cx.quit();
});
}
_ => {}
}
}
}
});
Event Handler Pattern (alternative)
For winit/tao integration, use event handlers instead of polling:
use tray_icon::{TrayIconEvent, menu::MenuEvent};
// Forward to event loop
let proxy = event_loop.create_proxy();
TrayIconEvent::set_event_handler(Some(move |event| {
proxy.send_event(UserEvent::TrayIconEvent(event));
}));
let proxy = event_loop.create_proxy();
MenuEvent::set_event_handler(Some(move |event| {
proxy.send_event(UserEvent::MenuEvent(event));
}));
Updating Menu State
// Update checkbox state
self.launch_at_login_item.set_checked(true);
// Update menu item text
menu_item.set_text("New Text");
// Enable/disable
menu_item.set_enabled(false);
Platform Notes
macOS
- Threading: Must create tray on main thread
- Event loop: Requires NSApplication run loop
- Template images: Use
with_icon_as_template(true)for automatic light/dark adaptation - Timing: Create after Application::new() initializes NSApplication
Windows
- Threading: Create on same thread as win32 event loop
- Accelerators: Need
TranslateAcceleratorWin message loop
Linux
- Backend: Uses gtk + libappindicator (or libayatana-appindicator)
- Dependencies:
gtk3 xdotool libappindicator3-dev
Anti-patterns
❌ Dropping TrayIcon
// WRONG: Icon disappears immediately!
fn setup_tray() {
let tray = TrayIconBuilder::new().build().unwrap();
// tray dropped here, icon vanishes
}
// CORRECT: Store in struct
struct App {
tray: TrayIcon, // Lives as long as App
}
❌ Using Menu for Tray Context Menu
// WRONG: Menu::append only allows Submenu on macOS
let menu = Menu::new();
menu.append(&MenuItem::new("Item", true, None)); // Error on macOS!
// CORRECT: Use Submenu as root
let menu = Submenu::with_id("root", "Menu", true);
menu.append(&MenuItem::new("Item", true, None)); // Works everywhere
❌ Hardcoded String IDs
// WRONG: Error-prone, no compile-time checking
if event.id.0 == "open_app" { ... } // Typo risk
// CORRECT: Enum with const IDs
if let Some(TrayMenuAction::OpenApp) = TrayMenuAction::from_id(&event.id.0) {
// Type-safe matching
}
❌ Ignoring Icon Rendering Failures
// WRONG: Silent failure
let icon = Icon::from_rgba(rgba, 32, 32).ok(); // Swallows errors
// CORRECT: Validate and log
match render_svg_to_rgba(svg, 32, 32) {
Ok(rgba) => MenuIcon::from_rgba(rgba, 32, 32).ok(),
Err(e) => {
warn!("Failed to render icon: {}", e);
None
}
}
❌ Blocking the Event Loop
// WRONG: Blocking poll
loop {
if let Ok(event) = MenuEvent::receiver().recv() { // Blocks!
handle(event);
}
}
// CORRECT: Non-blocking poll with timer
loop {
Timer::after(Duration::from_millis(100)).await;
if let Ok(event) = MenuEvent::receiver().try_recv() { // Non-blocking
handle(event);
}
}
Dependencies
Add to Cargo.toml:
[dependencies]
tray-icon = "0.21"
# For SVG icon rendering
usvg = "0.43"
resvg = "0.43"
tiny-skia = "0.11"
See Also
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
Generate Component Documentation
Based on existing docs styles and specific API implementations, and referencing same name stories, generate comprehensive documentation for the new component.
Generate Component Story
Generate a comprehensive story for a new component for as example.
new-component
How to write a new component of GPUI Component.
troubleshooting
Diagnose and fix common Script Kit issues. Use when the user reports bugs, crashes, missing features, or unexpected behavior in Script Kit GPUI.
script-authoring
Create and manage TypeScript scripts for Script Kit. Use when the user wants to write a new script, edit an existing script, or understand Script Kit's SDK and metadata system.
agents
Create mdflow-backed agent files for Script Kit. Use when the user wants to create AI agents, configure agent backends (Claude, Gemini, Codex), or manage agent metadata.
Didn't find tool you were looking for?