Skip to main content

demo_tray/
demo_tray.rs

1//! A runnable demo: installs a real macOS tray icon whose click opens muri's
2//! custom-drawn, styled popup — provider groups with flush-right colored values
3//! (no chevron column), a checkmark on the active account, separators, **flyout
4//! submenus** (the account detail panel and Settings open a second panel beside
5//! the row), and a greyed version tail on Quit.
6//!
7//! ```sh
8//! cargo run --example demo_tray
9//! ```
10//!
11//! On macOS this shows a status-bar icon; left-click it to open the popup, hover
12//! to highlight a row, hover a `›` row to open its flyout to the right, click a
13//! leaf row to fire its id (printed to stdout) and close, or click outside to
14//! dismiss. Clicking "Quit" exits. On other platforms `Tray::run` is not
15//! implemented yet (Windows) or unsupported (Linux).
16
17use muri::render::Framebuffer;
18use muri::{Align, Color, Flex, Font, Icon, Menu, Row, Segment, StyleRun, Tray, Weight};
19
20/// Build a tiny solid-color circular 16×16 PNG so the demo ships no binary asset.
21fn swatch_png(r: u8, g: u8, b: u8) -> Vec<u8> {
22    let mut fb = Framebuffer::new(16, 16);
23    let px = fb.pixels_mut();
24    for i in 0..256usize {
25        let (x, y) = ((i % 16) as i32 - 8, (i / 16) as i32 - 8);
26        let a: u16 = if x * x + y * y <= 49 { 255 } else { 0 };
27        let o = i * 4;
28        // Premultiplied RGBA (what `Framebuffer` stores).
29        px[o] = (r as u16 * a / 255) as u8;
30        px[o + 1] = (g as u16 * a / 255) as u8;
31        px[o + 2] = (b as u16 * a / 255) as u8;
32        px[o + 3] = a as u8;
33    }
34    fb.encode_png()
35}
36
37/// The per-account detail panel shown as a flyout beside an account row: reset
38/// windows (flush-right values), an "updated" line, then the account actions.
39fn account_submenu(slug: &str) -> Menu {
40    Menu::new()
41        .row(Row::default().segments(vec![
42            Segment::new("Session resets in").flex(Flex::Grow),
43            Segment::new("3h 12m")
44                .align(Align::Right)
45                .color(Color::SecondaryLabel),
46        ]))
47        .row(Row::default().segments(vec![
48            Segment::new("Weekly resets in").flex(Flex::Grow),
49            Segment::new("2d 4h")
50                .align(Align::Right)
51                .color(Color::SecondaryLabel),
52        ]))
53        .row(Row::default().segment(Segment::new("updated 1m ago").color(Color::SecondaryLabel)))
54        .separator()
55        .row(Row::new(format!("switch:{slug}")).label("Switch to this account"))
56        .row(Row::new(format!("launch:{slug}")).label("Launch client"))
57        .row(Row::new(format!("remove:{slug}")).label("Remove…"))
58}
59
60fn demo_menu() -> Menu {
61    let claude_logo = swatch_png(217, 119, 87);
62    let codex_logo = swatch_png(80, 80, 90);
63
64    Menu::new()
65        .section_header(
66            Row::default()
67                .leading(Icon::from_png(claude_logo))
68                .segment(Segment::new("Claude").font(Font::system(13.0, Weight::Bold))),
69        )
70        .submenu(
71            Row::new("acct:claude:me")
72                .leading(Icon::Checkmark)
73                .checked(true)
74                .segments(vec![
75                    Segment::new("me@example.com")
76                        .flex(Flex::Grow)
77                        .font(Font::system(13.0, Weight::Bold)),
78                    Segment::new("47% / 89%")
79                        .align(Align::Right)
80                        .runs(vec![StyleRun::new(6, 3, Color::SystemRed)]),
81                ]),
82            account_submenu("claude:me"),
83        )
84        .submenu(
85            Row::new("acct:claude:work").segments(vec![
86                Segment::new("work@example.com").flex(Flex::Grow),
87                Segment::new("12% / 30%")
88                    .align(Align::Right)
89                    .runs(vec![StyleRun::new(0, 3, Color::SystemGreen)]),
90            ]),
91            account_submenu("claude:work"),
92        )
93        .separator()
94        .section_header(
95            Row::default()
96                .leading(Icon::from_png(codex_logo))
97                .segment(Segment::new("Codex").font(Font::system(13.0, Weight::Bold))),
98        )
99        .row(Row::new("switch:codex:me").segments(vec![
100            Segment::new("me@example.com").flex(Flex::Grow),
101            Segment::new("3h 12m")
102                .align(Align::Right)
103                .runs(vec![StyleRun::new(0, 6, Color::SystemOrange)]),
104        ]))
105        .separator()
106        .submenu(
107            Row::new("settings").label("Settings"),
108            Menu::new()
109                .row(Row::new("settings:apikey").label("API key…"))
110                .row(Row::new("settings:autoswap").label("Auto-swap accounts"))
111                .separator()
112                .row(Row::new("settings:notifications").label("Notifications…")),
113        )
114        .row(Row::new("quit").segments(vec![
115            Segment::new("Quit").flex(Flex::Grow),
116            Segment::new(concat!("usagio v", env!("CARGO_PKG_VERSION")))
117                .align(Align::Right)
118                .color(Color::SecondaryLabel),
119        ]))
120}
121
122fn main() {
123    let icon = Icon::from_png(swatch_png(120, 170, 255));
124    let tray = Tray::new(icon)
125        .tooltip("muri demo")
126        .menu(demo_menu())
127        .on_click(|id| {
128            println!("clicked: {}", id.as_str());
129            if id.as_str() == "quit" {
130                std::process::exit(0);
131            }
132        });
133
134    println!("muri demo_tray: click the status-bar icon to open the styled popup.");
135    // The tray owns the platform status item, which has main-thread affinity on
136    // macOS; `Tray::run` proves that at compile time by taking a MainThreadMarker
137    // (issue #46). `main` runs on the main thread, so this is always `Some`.
138    let Some(mtm) = muri::MainThreadMarker::new() else {
139        eprintln!("tray error: must be started on the main thread");
140        return;
141    };
142    if let Err(e) = tray.run(mtm) {
143        eprintln!("tray error: {e}");
144    }
145}