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::label_only("Session resets in 2h 41m"))
60        .row(Row::label_only("Weekly resets in 3d 4h"))
61        .row(Row::default().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(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(
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::default()
147                .leading(Icon::from_png(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            // A rich content row (#44) is a display-only layout stack, not a
239            // labelled row; this text dump just marks its presence.
240            Item::Content(_) => println!("{pad}[content]"),
241        }
242    }
243}
244
245fn row_text(row: &Row) -> String {
246    row.segments
247        .iter()
248        .map(|s| s.text.as_str())
249        .collect::<Vec<_>>()
250        .join("  ")
251}
252
253fn demo_theme_resolution() {
254    // A consumer can resolve semantic colors against any theme with no GUI.
255    let dark = Theme::dark();
256    let label = dark.resolve(Color::Label);
257    let red = dark.resolve(Color::SystemRed);
258    println!(
259        "theme resolution (dark): Label -> rgba({},{},{},{}), SystemRed -> rgba({},{},{},{})",
260        label.r, label.g, label.b, label.a, red.r, red.g, red.b, red.a,
261    );
262}
263
264fn demo_flush_right_layout() {
265    // The "Quit ...... usagio v1" row: a Grow label + a Fixed, right-aligned
266    // version tail. Given measured intrinsic widths, the layout engine flushes
267    // the tail to the right edge with no reserved column.
268    let content_width = 220.0;
269    let segs = [
270        SegmentMetrics::new(30.0, Flex::Grow, Align::Left), // "Quit"
271        SegmentMetrics::new(60.0, Flex::Fixed, Align::Right), // "usagio v1"
272    ];
273    let boxes = resolve_segments(&segs, content_width);
274    println!(
275        "flush-right layout: content {content_width}px -> tail text starts at x={} (right edge {})",
276        boxes[1].text_x,
277        content_width - 60.0,
278    );
279}
280
281fn demo_context_menu() {
282    // The pointer-anchored primitive that also works on Linux.
283    let menu = Menu::new()
284        .row(Row::new("copy").label("Copy"))
285        .row(Row::new("paste").label("Paste"))
286        .separator()
287        .row(Row::new("select-all").label("Select All"));
288    let cm = ContextMenu::new(menu).on_click(|id| println!("context click: {}", id.as_str()));
289    // Exercise the dispatch path without a GUI.
290    cm.dispatch(&"copy".into());
291    println!("context menu has {} items", cm.menu().len());
292}
293
294fn main() {
295    let groups = vec![
296        Group {
297            display_name: "Claude",
298            icon_png: b"<claude.png bytes>",
299            accounts: vec![
300                Account {
301                    provider: "claude",
302                    key: "me@example.com",
303                    display: "me@example.com",
304                    trailing: "47% / 89%",
305                    // color the "89%" span red (utf16 offset 6, len 3)
306                    severity: vec![(6, 3, Color::SystemRed)],
307                    active: true,
308                    supports_launch: true,
309                    supports_remove: true,
310                },
311                Account {
312                    provider: "claude",
313                    key: "work@example.com",
314                    display: "work@example.com",
315                    trailing: "12% / 30%",
316                    severity: vec![],
317                    active: false,
318                    supports_launch: true,
319                    supports_remove: true,
320                },
321            ],
322        },
323        Group {
324            display_name: "Codex",
325            icon_png: b"<codex.png bytes>",
326            accounts: vec![Account {
327                provider: "codex",
328                key: "me@example.com",
329                display: "me@example.com",
330                trailing: "3h 12m",
331                severity: vec![(0, 6, Color::SystemOrange)],
332                active: false,
333                supports_launch: false,
334                supports_remove: true,
335            }],
336        },
337    ];
338
339    let menu = build(&groups);
340    println!("usagio menu, as built through the muri API:\n");
341    print_menu(&menu, 0);
342
343    println!("\n--- pure API demos (no GUI needed) ---");
344    demo_theme_resolution();
345    demo_flush_right_layout();
346    demo_context_menu();
347}