1use muri::layout::{resolve_segments, SegmentMetrics};
22use muri::{
23 Align, Color, ContextMenu, Flex, Font, Icon, Item, Menu, Row, Segment, StyleRun, Theme, Weight,
24};
25
26struct Account {
28 provider: &'static str,
29 key: &'static str,
30 display: &'static str,
31 trailing: &'static str,
33 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 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
54fn account_submenu(acct: &Account) -> Menu {
57 let mut menu = Menu::new()
58 .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 .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
93fn 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
106fn 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 .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 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 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 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 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 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 let content_width = 220.0;
266 let segs = [
267 SegmentMetrics::new(30.0, Flex::Grow, Align::Left), SegmentMetrics::new(60.0, Flex::Fixed, Align::Right), ];
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 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 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 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}