Skip to main content

usagio_menu/
usagio_menu.rs

1//! Rebuilds usagio's real tray menu through the muri API, proving the API can
2//! express every feature usagio's hand-rolled `RowStyle` needs: provider group
3//! headers with logos, accounts with a flush-right colored "S% / W%" value and
4//! NO reserved chevron column, a checkmark + bold on the active account,
5//! per-account detail submenus, populated Capture/Settings submenus with nested
6//! flyouts and checkable rows, leading/trailing icons (PNG, SVG, checkmark, and
7//! a named symbol), and a greyed version tail on Quit.
8//!
9//! It also shows the pure, GUI-free parts of the API a consumer can rely on
10//! today: resolving semantic colors against a custom [`muri::Theme`], resolving
11//! the flush-right layout with [`muri::layout`], and building a
12//! [`muri::ContextMenu`] for the pointer-anchored path.
13//!
14//! This constructs the menu and prints a tree; it does not open a UI (the
15//! rendering backend is not implemented yet). Run with:
16//!
17//! ```sh
18//! cargo run --example usagio_menu
19//! ```
20
21use muri::layout::{resolve_segments, SegmentMetrics};
22use muri::{
23    Align, Color, ContextMenu, Flex, Font, Icon, Item, Menu, Row, Segment, StyleRun, Theme, Weight,
24};
25
26/// A stand-in for usagio's `Snapshot` so this example is self-contained.
27struct Account {
28    provider: &'static str,
29    key: &'static str,
30    display: &'static str,
31    /// e.g. "47% / 89%" or a locked countdown "3h 12m"
32    trailing: &'static str,
33    /// (utf16_start, utf16_len, color) severity spans within `trailing`
34    severity: Vec<(usize, usize, Color)>,
35    active: bool,
36    supports_launch: bool,
37    supports_remove: bool,
38}
39
40struct Group {
41    display_name: &'static str,
42    // In the real crate this is the 16px provider logo PNG bytes.
43    icon_png: &'static [u8],
44    accounts: Vec<Account>,
45}
46
47fn severity_runs(spans: &[(usize, usize, Color)]) -> Vec<StyleRun> {
48    spans
49        .iter()
50        .map(|&(start, len, color)| StyleRun::new(start, len, color))
51        .collect()
52}
53
54/// The per-account detail submenu: reset-window info rows, an "updated …" line,
55/// then Switch / Launch / Remove actions.
56fn account_submenu(acct: &Account) -> Menu {
57    let mut menu = Menu::new()
58        // Info rows read as normal text but are non-interactive (id = none).
59        .row(Row::info().label("Session resets in 2h 41m"))
60        .row(Row::info().label("Weekly resets in 3d 4h"))
61        .row(Row::info().segment(Segment::new("updated 12s ago").color(Color::SecondaryLabel)))
62        .separator();
63
64    if acct.active {
65        menu = menu.row(
66            Row::new("noop")
67                .leading(Icon::Checkmark)
68                .segment(Segment::new("Active").color(Color::SystemGreen))
69                .enabled(false),
70        );
71    } else {
72        menu = menu.row(
73            Row::new(format!("switch:{}:{}", acct.provider, acct.key))
74                .label("Switch to this account"),
75        );
76    }
77    if acct.supports_launch {
78        menu = menu.row(
79            Row::new(format!("launch:{}:{}", acct.provider, acct.key))
80                .label("Launch client")
81                // A named symbol trails the action (SF Symbol on macOS).
82                .trailing(Icon::Symbol("arrow.up.forward.app")),
83        );
84    }
85    if acct.supports_remove {
86        menu = menu.row(
87            Row::new(format!("remove:{}:{}", acct.provider, acct.key)).label("Remove\u{2026}"),
88        );
89    }
90    menu
91}
92
93/// The Capture submenu: one entry per provider to capture the current login.
94fn capture_submenu(groups: &[Group]) -> Menu {
95    let mut menu = Menu::new();
96    for group in groups {
97        menu = menu.row(
98            Row::new(format!("capture:{}", group.display_name.to_lowercase()))
99                .leading(Icon::from_png_bytes(group.icon_png))
100                .label(format!("Capture {} login", group.display_name)),
101        );
102    }
103    menu
104}
105
106/// The Settings submenu: a checkable toggle, an auto-swap threshold flyout, and
107/// a backup flyout — exercising nested submenus, `checked`, and an SVG icon.
108fn settings_submenu() -> Menu {
109    let autoswap = Menu::new()
110        .row(Row::new("autoswap:off").checked(false).label("Off"))
111        .row(Row::new("autoswap:80").checked(false).label("At 80%"))
112        .row(Row::new("autoswap:90").checked(true).label("At 90%"))
113        .separator()
114        .row(Row::new("autoswap:now").label("Swap now"));
115
116    let backup = Menu::new()
117        .row(Row::new("backup:save").label("Save backup\u{2026}"))
118        .row(Row::new("backup:restore").label("Restore backup\u{2026}"));
119
120    Menu::new()
121        .row(
122            Row::new("notifications:limits")
123                .checked(true)
124                .label("Notify near limits"),
125        )
126        .submenu(Row::new("autoswap").label("Auto-swap threshold"), autoswap)
127        .submenu(
128            Row::new("backup")
129                // An SVG leading icon, rasterized per-DPI at draw time.
130                .leading(Icon::from_svg_bytes(
131                    br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#.as_slice(),
132                ))
133                .label("Backup"),
134            backup,
135        )
136        .separator()
137        .row(Row::new("refresh:now").label("Refresh now"))
138}
139
140fn build(groups: &[Group]) -> Menu {
141    let mut menu = Menu::new();
142
143    for group in groups {
144        // Bold, logo'd, non-interactive group header (Claude, then Codex).
145        menu = menu.section_header(
146            Row::info()
147                .leading(Icon::from_png_bytes(group.icon_png))
148                .segment(Segment::new(group.display_name).font(Font::system(13.0, Weight::Bold))),
149        );
150
151        for acct in &group.accounts {
152            // The label Grows to eat the leftover width, so the value segment
153            // sits flush at the true right edge — no reserved chevron column.
154            let label = Segment::new(acct.display)
155                .flex(Flex::Grow)
156                .font(if acct.active {
157                    Font::system(13.0, Weight::Bold)
158                } else {
159                    Font::system(13.0, Weight::Regular)
160                });
161
162            let value = Segment::new(acct.trailing)
163                .align(Align::Right)
164                .runs(severity_runs(&acct.severity));
165
166            let mut label_row = Row::new(format!("switch:{}:{}", acct.provider, acct.key))
167                .segments(vec![label, value])
168                .checked(acct.active);
169            if acct.active {
170                label_row = label_row.leading(Icon::Checkmark);
171            }
172
173            menu = menu.submenu(label_row, account_submenu(acct));
174        }
175
176        menu = menu.separator();
177    }
178
179    // Bottom actions — now with populated submenus.
180    menu = menu
181        .submenu(
182            Row::new("capture").label("Capture current login"),
183            capture_submenu(groups),
184        )
185        .submenu(Row::new("settings").label("Settings"), settings_submenu());
186
187    // "Quit ........ usagio vX" — label Grows, version tail is greyed + flush-right.
188    menu = menu.row(Row::new("quit").segments(vec![
189        Segment::new("Quit").flex(Flex::Grow),
190        Segment::new(format!("usagio v{}", env!("CARGO_PKG_VERSION")))
191            .align(Align::Right)
192            .color(Color::SecondaryLabel),
193    ]));
194
195    menu
196}
197
198fn icon_tag(icon: &Option<Icon>) -> &'static str {
199    match icon {
200        None => "",
201        Some(Icon::Checkmark) => " [check]",
202        Some(Icon::Png(_)) => " [png]",
203        Some(Icon::Svg(_)) => " [svg]",
204        Some(Icon::Symbol(_)) => " [symbol]",
205    }
206}
207
208fn print_row(row: &Row, prefix: &str, pad: &str) {
209    let check = match row.checked {
210        Some(true) => "[x] ",
211        Some(false) => "[ ] ",
212        None => "",
213    };
214    let dim = if row.enabled { "" } else { " (disabled)" };
215    println!(
216        "{pad}{prefix}{}{check}{}{}{}  [{}]",
217        icon_tag(&row.leading),
218        row_text(row),
219        icon_tag(&row.trailing),
220        dim,
221        row.id.as_str(),
222    );
223}
224
225fn print_menu(menu: &Menu, depth: usize) {
226    let pad = "  ".repeat(depth);
227    for item in &menu.items {
228        match item {
229            Item::Separator => println!("{pad}----"),
230            Item::SectionHeader(row) => {
231                println!("{pad}#{} {}", icon_tag(&row.leading), row_text(row))
232            }
233            Item::Row(row) => print_row(row, "- ", &pad),
234            Item::Submenu { label, menu } => {
235                print_row(label, "> ", &pad);
236                print_menu(menu, depth + 1);
237            }
238        }
239    }
240}
241
242fn row_text(row: &Row) -> String {
243    row.segments
244        .iter()
245        .map(|s| s.text.as_str())
246        .collect::<Vec<_>>()
247        .join("  ")
248}
249
250fn demo_theme_resolution() {
251    // A consumer can resolve semantic colors against any theme with no GUI.
252    let dark = Theme::dark();
253    let label = dark.resolve(Color::Label);
254    let red = dark.resolve(Color::SystemRed);
255    println!(
256        "theme resolution (dark): Label -> rgba({},{},{},{}), SystemRed -> rgba({},{},{},{})",
257        label.r, label.g, label.b, label.a, red.r, red.g, red.b, red.a,
258    );
259}
260
261fn demo_flush_right_layout() {
262    // The "Quit ...... usagio v1" row: a Grow label + a Fixed, right-aligned
263    // version tail. Given measured intrinsic widths, the layout engine flushes
264    // the tail to the right edge with no reserved column.
265    let content_width = 220.0;
266    let segs = [
267        SegmentMetrics::new(30.0, Flex::Grow, Align::Left), // "Quit"
268        SegmentMetrics::new(60.0, Flex::Fixed, Align::Right), // "usagio v1"
269    ];
270    let boxes = resolve_segments(&segs, content_width);
271    println!(
272        "flush-right layout: content {content_width}px -> tail text starts at x={} (right edge {})",
273        boxes[1].text_x,
274        content_width - 60.0,
275    );
276}
277
278fn demo_context_menu() {
279    // The pointer-anchored primitive that also works on Linux.
280    let menu = Menu::new()
281        .row(Row::new("copy").label("Copy"))
282        .row(Row::new("paste").label("Paste"))
283        .separator()
284        .row(Row::new("select-all").label("Select All"));
285    let cm = ContextMenu::new(menu).on_click(|id| println!("context click: {}", id.as_str()));
286    // Exercise the dispatch path without a GUI.
287    cm.dispatch(&"copy".into());
288    println!("context menu has {} items", cm.menu().len());
289}
290
291fn main() {
292    let groups = vec![
293        Group {
294            display_name: "Claude",
295            icon_png: b"<claude.png bytes>",
296            accounts: vec![
297                Account {
298                    provider: "claude",
299                    key: "me@example.com",
300                    display: "me@example.com",
301                    trailing: "47% / 89%",
302                    // color the "89%" span red (utf16 offset 6, len 3)
303                    severity: vec![(6, 3, Color::SystemRed)],
304                    active: true,
305                    supports_launch: true,
306                    supports_remove: true,
307                },
308                Account {
309                    provider: "claude",
310                    key: "work@example.com",
311                    display: "work@example.com",
312                    trailing: "12% / 30%",
313                    severity: vec![],
314                    active: false,
315                    supports_launch: true,
316                    supports_remove: true,
317                },
318            ],
319        },
320        Group {
321            display_name: "Codex",
322            icon_png: b"<codex.png bytes>",
323            accounts: vec![Account {
324                provider: "codex",
325                key: "me@example.com",
326                display: "me@example.com",
327                trailing: "3h 12m",
328                severity: vec![(0, 6, Color::SystemOrange)],
329                active: false,
330                supports_launch: false,
331                supports_remove: true,
332            }],
333        },
334    ];
335
336    let menu = build(&groups);
337    println!("usagio menu, as built through the muri API:\n");
338    print_menu(&menu, 0);
339
340    println!("\n--- pure API demos (no GUI needed) ---");
341    demo_theme_resolution();
342    demo_flush_right_layout();
343    demo_context_menu();
344}