Krolyxon
Commit 19d99c1

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

768 lines · 26.1 KB · Rust

1//! `budctl-gui`: control wireless earbuds from Linux, in a window.
2//!
3//! A thin layer over budctl-core, like the CLI. For now it shows demo data: the look comes first,
4//! the live connection later (CLAUDE.md, GUI). The mouse comes first; vim-like keys work on top
5//! (`keys.rs`).
6
7mod demo;
8mod icons;
9mod keys;
10mod overview;
11mod theme;
12mod ui;
13
14use std::path::PathBuf;
15use std::process::ExitCode;
16
17use budctl_core::status::{AncLevel, NoiseControl, NoiseMode};
18use iced::keyboard::{self, Key, Modifiers};
19use iced::widget::operation::{self, RelativeOffset};
20use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text};
21use iced::{Alignment, Border, Color, Element, Font, Length, Size, Subscription, Task, Theme, event, mouse, window};
22
23use icons::Icon;
24use keys::Action;
25use theme::Look;
26
27const USAGE: &str = "usage: budctl-gui [--theme <palette.json>]
28
29Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in.
30The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …).
31Press ? in the window for the keys.";
32
33/// The page's scrolling area, so the cursor can be kept in view.
34const SCROLL: &str = "page";
35
36fn main() -> ExitCode {
37 let theme_file = match parse_args(std::env::args().skip(1)) {
38 Ok(Args::Run { theme }) => theme.or_else(theme::default_file),
39 Ok(Args::Help) => {
40 println!("{USAGE}");
41 return ExitCode::SUCCESS;
42 }
43 Err(message) => {
44 eprintln!("budctl-gui: {message}\n{USAGE}");
45 return ExitCode::from(1);
46 }
47 };
48 let font = theme::system_font();
49 let result = iced::application(move || App::new(theme_file.clone(), font), App::update, App::view)
50 .title("budctl")
51 .theme(App::theme)
52 .subscription(App::subscription)
53 .default_font(font)
54 .window(window::Settings {
55 size: Size::new(1040.0, 800.0),
56 min_size: Some(Size::new(760.0, 540.0)),
57 platform_specific: window::settings::PlatformSpecific {
58 application_id: "budctl".to_owned(),
59 ..window::settings::PlatformSpecific::default()
60 },
61 ..window::Settings::default()
62 })
63 .run();
64 match result {
65 Ok(()) => ExitCode::SUCCESS,
66 Err(e) => {
67 eprintln!("budctl-gui: {e}");
68 ExitCode::FAILURE
69 }
70 }
71}
72
73enum Args {
74 Run { theme: Option<PathBuf> },
75 Help,
76}
77
78fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
79 let mut theme = None;
80 while let Some(arg) = args.next() {
81 match arg.as_str() {
82 "--theme" => theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)),
83 "-h" | "--help" => return Ok(Args::Help),
84 other => return Err(format!("unknown argument {other:?}")),
85 }
86 }
87 Ok(Args::Run { theme })
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum Page {
92 Overview,
93 Equalizer,
94 Gestures,
95 Earbuds,
96 Settings,
97}
98
99impl Page {
100 const ALL: [Page; 5] = [
101 Page::Overview,
102 Page::Equalizer,
103 Page::Gestures,
104 Page::Earbuds,
105 Page::Settings,
106 ];
107
108 fn title(self) -> &'static str {
109 match self {
110 Page::Overview => "Overview",
111 Page::Equalizer => "Equalizer",
112 Page::Gestures => "Gestures",
113 Page::Earbuds => "Earbud settings",
114 Page::Settings => "App settings",
115 }
116 }
117
118 fn icon(self) -> Icon {
119 match self {
120 Page::Overview => Icon::Overview,
121 Page::Equalizer => Icon::Equalizer,
122 Page::Gestures => Icon::Gestures,
123 Page::Earbuds => Icon::Earbud,
124 Page::Settings => Icon::Settings,
125 }
126 }
127
128 /// The next page in the sidebar, or the previous one, going round.
129 fn step(self, forward: bool) -> Page {
130 let count = Page::ALL.len();
131 let at = Page::ALL.iter().position(|&page| page == self).unwrap_or(0);
132 let to = if forward { at + 1 } else { at + count - 1 };
133 Page::ALL.get(to % count).copied().unwrap_or(self)
134 }
135}
136
137/// What the keyboard cursor can rest on.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum Item {
140 NoiseMode,
141 AncLevel,
142 /// A switch, by its key.
143 Switch(&'static str),
144 /// A row that opens a page.
145 Open(Page),
146}
147
148#[derive(Debug, Clone)]
149enum Message {
150 Open(Page),
151 Noise(NoiseMode),
152 /// A switch's key, and whether to turn it on.
153 Switch(&'static str, bool),
154 Connect(bool),
155 /// The window got focus: the palette file may have changed meanwhile.
156 Focused,
157 Key(Key, Modifiers),
158 /// A mouse button went down: the mouse is in use, so the keyboard cursor hides.
159 Pointer,
160 CloseHelp,
161}
162
163struct App {
164 page: Page,
165 buds: demo::Buds,
166 /// The level "Noise cancelling" turns on at: the one last reported or picked.
167 anc_level: AncLevel,
168 look: Look,
169 theme: Theme,
170 palette: theme::Source,
171 keys: keys::Keys,
172 /// Where the keyboard cursor is, among [`App::items`].
173 cursor: usize,
174 /// Whether it shows. Only keys show it; a click hides it.
175 cursor_shown: bool,
176 /// Whether the list of keys is open.
177 help: bool,
178}
179
180impl App {
181 fn new(theme_file: Option<PathBuf>, font: Font) -> Self {
182 let buds = demo::buds();
183 let anc_level = buds
184 .status
185 .noise_control
186 .as_ref()
187 .and_then(|now| now.level.as_deref())
188 .and_then(anc_level)
189 .unwrap_or(AncLevel::Deep);
190 let mut app = App {
191 page: Page::Overview,
192 buds,
193 anc_level,
194 look: Look {
195 p: theme::BUILT_IN,
196 font,
197 },
198 theme: theme::iced_theme(&theme::BUILT_IN),
199 palette: theme::Source::new(theme_file),
200 keys: keys::Keys::default(),
201 cursor: 0,
202 cursor_shown: false,
203 help: false,
204 };
205 app.refresh_palette();
206 app
207 }
208
209 fn update(&mut self, message: Message) -> Task<Message> {
210 match message {
211 Message::Open(page) => return self.open(page),
212 Message::Noise(mode) => {
213 let level = match mode {
214 NoiseMode::Anc(level) => {
215 self.anc_level = level;
216 Some(level.name().to_owned())
217 }
218 NoiseMode::Off | NoiseMode::Transparency => None,
219 };
220 self.buds.status.noise_control = Some(NoiseControl {
221 mode: mode.name().to_owned(),
222 level,
223 });
224 }
225 Message::Switch(key, on) => {
226 if let Some(switch) = self.buds.status.switches.iter_mut().find(|s| s.key == key) {
227 switch.on = on;
228 }
229 }
230 Message::Connect(on) => self.buds.connected = on,
231 Message::Focused => self.refresh_palette(),
232 Message::Key(key, modifiers) => return self.key(&key, modifiers),
233 Message::Pointer => self.cursor_shown = false,
234 Message::CloseHelp => self.help = false,
235 }
236 Task::none()
237 }
238
239 /// Shows `page` from its top, with the cursor on its first item.
240 fn open(&mut self, page: Page) -> Task<Message> {
241 self.page = page;
242 self.cursor = 0;
243 operation::snap_to(SCROLL, RelativeOffset::START)
244 }
245
246 fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> {
247 let Some(action) = self.keys.press(key, modifiers) else {
248 return Task::none();
249 };
250 if self.help {
251 // The list of keys is in front of everything: a key only closes it.
252 self.help = false;
253 return Task::none();
254 }
255 match action {
256 Action::Quit => iced::exit(),
257 Action::Help => {
258 self.help = true;
259 Task::none()
260 }
261 Action::Escape => {
262 self.cursor_shown = false;
263 Task::none()
264 }
265 Action::NextPage => self.open(self.page.step(true)),
266 Action::PreviousPage => self.open(self.page.step(false)),
267 Action::Page(n) => match Page::ALL.get(n) {
268 Some(&page) => self.open(page),
269 None => Task::none(),
270 },
271 Action::Down
272 | Action::Up
273 | Action::First
274 | Action::Last
275 | Action::Left
276 | Action::Right
277 | Action::Activate => self.on_cursor(action),
278 }
279 }
280
281 /// What the current page offers the keyboard cursor, top to bottom.
282 fn items(&self) -> Vec<Item> {
283 match self.page {
284 Page::Overview => overview::items(&self.buds),
285 _ => Vec::new(),
286 }
287 }
288
289 /// The item under the cursor, when the cursor shows.
290 fn focus(&self) -> Option<Item> {
291 let items = self.items();
292 let last = items.len().checked_sub(1)?;
293 self.cursor_shown.then(|| items.get(self.cursor.min(last)).copied())?
294 }
295
296 fn on_cursor(&mut self, action: Action) -> Task<Message> {
297 let items = self.items();
298 let Some(last) = items.len().checked_sub(1) else {
299 return Task::none();
300 };
301 self.cursor = self.cursor.min(last);
302 if !self.cursor_shown {
303 // The first key only shows the cursor, so nothing changes where it cannot be seen.
304 self.cursor_shown = true;
305 return self.follow();
306 }
307 match action {
308 Action::Down => self.cursor = (self.cursor + 1).min(last),
309 Action::Up => self.cursor = self.cursor.saturating_sub(1),
310 Action::First => self.cursor = 0,
311 Action::Last => self.cursor = last,
312 _ => {
313 let change = items.get(self.cursor).and_then(|&item| self.change(item, action));
314 return match change {
315 Some(message) => self.update(message),
316 None => Task::none(),
317 };
318 }
319 }
320 self.follow()
321 }
322
323 /// What `action` (`h`, `l` or Enter) does to `item`, if anything.
324 fn change(&self, item: Item, action: Action) -> Option<Message> {
325 match item {
326 Item::NoiseMode => {
327 let modes = [NoiseMode::Off, NoiseMode::Anc(self.anc_level), NoiseMode::Transparency];
328 let now = self.buds.status.noise_control.as_ref()?;
329 let at = modes.iter().position(|mode| mode.name() == now.mode);
330 step(&modes, at, action).map(Message::Noise)
331 }
332 Item::AncLevel => {
333 let levels = [AncLevel::Light, AncLevel::Medium, AncLevel::Deep];
334 let at = levels.iter().position(|&level| level == self.anc_level);
335 step(&levels, at, action).map(|level| Message::Noise(NoiseMode::Anc(level)))
336 }
337 Item::Switch(key) => {
338 let on = self.buds.status.switches.iter().find(|s| s.key == key)?.on;
339 let want = match action {
340 Action::Left => false,
341 Action::Right => true,
342 _ => !on,
343 };
344 (want != on).then_some(Message::Switch(key, want))
345 }
346 Item::Open(page) => (action != Action::Left).then_some(Message::Open(page)),
347 }
348 }
349
350 /// Scrolls the page so the cursor stays in view: the first item at the top, the last at the
351 /// bottom, the others in between.
352 fn follow(&self) -> Task<Message> {
353 let count = self.items().len();
354 if !self.cursor_shown || count < 2 {
355 return Task::none();
356 }
357 let y = self.cursor as f32 / (count - 1) as f32;
358 operation::snap_to(SCROLL, RelativeOffset { x: 0.0, y })
359 }
360
361 fn refresh_palette(&mut self) {
362 let palette = match self.palette.refresh() {
363 None => return,
364 Some(Ok(palette)) => palette,
365 Some(Err(e)) => {
366 eprintln!("budctl-gui: {e}; using the built-in colors");
367 theme::BUILT_IN
368 }
369 };
370 self.look.p = palette;
371 self.theme = theme::iced_theme(&palette);
372 }
373
374 fn theme(&self) -> Theme {
375 self.theme.clone()
376 }
377
378 fn subscription(&self) -> Subscription<Message> {
379 event::listen_with(|event, _status, _window| match event {
380 iced::Event::Window(window::Event::Focused) => Some(Message::Focused),
381 iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
382 Some(Message::Key(key, modifiers))
383 }
384 iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer),
385 _ => None,
386 })
387 }
388
389 fn view(&self) -> Element<'_, Message> {
390 let look = self.look;
391 let page = match self.page {
392 Page::Overview => overview::view(look, &self.buds, self.anc_level, self.focus()),
393 page => column![
394 ui::title(look, page.title()),
395 text("This page comes in a later step.").size(14).color(look.p.dim)
396 ]
397 .spacing(12)
398 .into(),
399 };
400 let p = look.p;
401 let content = scrollable(
402 container(container(page).max_width(860))
403 .center_x(Length::Fill)
404 .padding([30, 36]),
405 )
406 .id(SCROLL)
407 .height(Length::Fill)
408 .style(move |theme, status| {
409 let mut style = scrollable::default(theme, status);
410 for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
411 rail.background = None;
412 rail.border = Border::default();
413 rail.scroller.background = p.surface_highest.into();
414 rail.scroller.border = Border::default().rounded(4);
415 }
416 style
417 });
418 let window = row![self.sidebar(), ui::divider(look), content];
419 if self.help {
420 stack![window, self.help()].into()
421 } else {
422 window.into()
423 }
424 }
425
426 fn sidebar(&self) -> Element<'_, Message> {
427 let look = self.look;
428 let p = look.p;
429 let connected = self.buds.connected;
430 let (light, state) = if connected {
431 (p.ok, "Connected")
432 } else {
433 (p.dim, "Not connected")
434 };
435 let device = container(
436 column![
437 text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg),
438 row![
439 ui::light(light),
440 text(state).size(13).color(p.dim),
441 space::horizontal(),
442 ui::tag(look, "demo")
443 ]
444 .spacing(8)
445 .align_y(Alignment::Center),
446 ui::outline_button(
447 look,
448 if connected { "Disconnect" } else { "Connect" },
449 Message::Connect(!connected)
450 ),
451 ]
452 .spacing(12),
453 )
454 .padding(16)
455 .style(move |_| container::Style {
456 background: Some(p.surface.into()),
457 border: Border {
458 color: p.line,
459 width: 1.0,
460 radius: 18.0.into(),
461 },
462 ..container::Style::default()
463 });
464 let pages = Page::ALL.into_iter().fold(Column::new().spacing(3), |pages, page| {
465 pages.push(ui::nav_item(
466 look,
467 page.icon(),
468 page.title(),
469 page == self.page,
470 Message::Open(page),
471 ))
472 });
473 let hint = text("Press ? for keys").size(12.5).color(p.dim);
474 container(column![device, pages, space::vertical(), hint].spacing(22))
475 .padding([18, 14])
476 .width(268)
477 .height(Length::Fill)
478 .style(move |_| container::Style {
479 background: Some(p.surface_low.into()),
480 ..container::Style::default()
481 })
482 .into()
483 }
484
485 /// The list of keys, over a dimmed window. A key or a click closes it.
486 fn help(&self) -> Element<'_, Message> {
487 let look = self.look;
488 let p = look.p;
489 let rows = keys::HELP.iter().fold(Column::new().spacing(9), |rows, &(keys, what)| {
490 rows.push(row![
491 text(keys).size(14).font(look.medium()).color(p.fg).width(170),
492 text(what).size(14).color(p.dim)
493 ])
494 });
495 let list = ui::panel(look, column![ui::title(look, "Keys"), rows].spacing(18).into());
496 let backdrop = container(container(list).width(560))
497 .center(Length::Fill)
498 .style(|_| container::Style {
499 background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
500 ..container::Style::default()
501 });
502 opaque(mouse_area(backdrop).on_press(Message::CloseHelp))
503 }
504}
505
506/// The choice `action` moves to from the one at `at`: `h` and `l` stop at the ends, Enter goes
507/// round. With nothing chosen, any of them picks the first.
508fn step<T: Copy>(choices: &[T], at: Option<usize>, action: Action) -> Option<T> {
509 let to = match (action, at) {
510 (_, None) => 0,
511 (Action::Left, Some(at)) => at.checked_sub(1)?,
512 (Action::Right, Some(at)) => at + 1,
513 (Action::Activate, Some(at)) => (at + 1).checked_rem(choices.len())?,
514 _ => return None,
515 };
516 choices.get(to).copied()
517}
518
519/// The level a [`NoiseControl::level`] names.
520fn anc_level(name: &str) -> Option<AncLevel> {
521 [AncLevel::Light, AncLevel::Medium, AncLevel::Deep]
522 .into_iter()
523 .find(|level| level.name() == name)
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use iced::keyboard::key::Named;
530 use std::fs;
531 use std::path::Path;
532
533 fn app() -> App {
534 App::new(None, Font::DEFAULT)
535 }
536
537 /// Presses keys, given by name and separated by spaces: `j`, `G`, `Enter`, `Esc`.
538 fn press(app: &mut App, keys: &str) {
539 for name in keys.split_whitespace() {
540 let (key, modifiers) = match name {
541 "Enter" => (Key::Named(Named::Enter), Modifiers::empty()),
542 "Esc" => (Key::Named(Named::Escape), Modifiers::empty()),
543 c if c.chars().all(char::is_uppercase) => (Key::Character(c.into()), Modifiers::SHIFT),
544 c => (Key::Character(c.into()), Modifiers::empty()),
545 };
546 let _ = app.update(Message::Key(key, modifiers));
547 }
548 }
549
550 fn mode(app: &App) -> &str {
551 &app.buds.status.noise_control.as_ref().unwrap().mode
552 }
553
554 fn switch(app: &App, key: &str) -> bool {
555 app.buds.status.switches.iter().find(|s| s.key == key).unwrap().on
556 }
557
558 #[test]
559 fn noise_cancelling_remembers_its_level() {
560 let mut app = app();
561 let _ = app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
562 let _ = app.update(Message::Noise(NoiseMode::Transparency));
563 assert_eq!(app.anc_level, AncLevel::Light);
564 let now = app.buds.status.noise_control.as_ref().unwrap();
565 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None));
566 let _ = app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
567 let now = app.buds.status.noise_control.as_ref().unwrap();
568 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
569 }
570
571 #[test]
572 fn switches_change_by_key() {
573 let mut app = app();
574 let _ = app.update(Message::Switch("game", true));
575 assert!(switch(&app, "game"));
576 }
577
578 #[test]
579 fn the_overview_items_in_order() {
580 let mut app = app();
581 assert_eq!(
582 app.items(),
583 [
584 Item::NoiseMode,
585 Item::AncLevel,
586 Item::Switch("game"),
587 Item::Switch("spatial"),
588 Item::Switch("bass-boost"),
589 Item::Switch("wind"),
590 Item::Switch("dual"),
591 Item::Open(Page::Equalizer),
592 Item::Open(Page::Gestures),
593 Item::Open(Page::Earbuds),
594 ]
595 );
596 let _ = app.update(Message::Noise(NoiseMode::Off));
597 assert!(!app.items().contains(&Item::AncLevel));
598 }
599
600 #[test]
601 fn the_first_key_only_shows_the_cursor() {
602 let mut app = app();
603 assert_eq!(app.focus(), None);
604 press(&mut app, "l");
605 assert_eq!(app.focus(), Some(Item::NoiseMode));
606 assert_eq!(mode(&app), "anc");
607 }
608
609 #[test]
610 fn h_and_l_step_through_choices_and_enter_goes_round() {
611 let mut app = app();
612 press(&mut app, "j l");
613 assert_eq!(mode(&app), "transparency");
614 press(&mut app, "l");
615 assert_eq!(mode(&app), "transparency");
616 press(&mut app, "h h h");
617 assert_eq!(mode(&app), "off");
618 press(&mut app, "Enter Enter");
619 assert_eq!(mode(&app), "transparency");
620 press(&mut app, "Enter");
621 assert_eq!(mode(&app), "off");
622 // On the strength: deep at first.
623 press(&mut app, "l j");
624 assert_eq!(app.focus(), Some(Item::AncLevel));
625 press(&mut app, "h h h");
626 assert_eq!(app.anc_level, AncLevel::Light);
627 press(&mut app, "Enter");
628 assert_eq!(app.anc_level, AncLevel::Medium);
629 assert_eq!(
630 app.buds.status.noise_control.as_ref().unwrap().level.as_deref(),
631 Some("medium")
632 );
633 }
634
635 #[test]
636 fn switches_take_h_l_and_enter() {
637 let mut app = app();
638 press(&mut app, "j j j");
639 assert_eq!(app.focus(), Some(Item::Switch("game")));
640 press(&mut app, "l");
641 assert!(switch(&app, "game"));
642 press(&mut app, "l");
643 assert!(switch(&app, "game"));
644 press(&mut app, "h");
645 assert!(!switch(&app, "game"));
646 press(&mut app, "Enter");
647 assert!(switch(&app, "game"));
648 }
649
650 #[test]
651 fn moving_stops_at_the_ends() {
652 let mut app = app();
653 press(&mut app, "k k");
654 assert_eq!(app.focus(), Some(Item::NoiseMode));
655 press(&mut app, "G j j");
656 assert_eq!(app.focus(), Some(Item::Open(Page::Earbuds)));
657 press(&mut app, "g g");
658 assert_eq!(app.focus(), Some(Item::NoiseMode));
659 }
660
661 #[test]
662 fn rows_open_pages_and_the_cursor_starts_over() {
663 let mut app = app();
664 press(&mut app, "G G h");
665 assert_eq!(app.page, Page::Overview);
666 press(&mut app, "l");
667 assert_eq!(app.page, Page::Earbuds);
668 assert_eq!(app.cursor, 0);
669 assert!(app.cursor_shown);
670 }
671
672 #[test]
673 fn pages_by_key() {
674 let mut app = app();
675 press(&mut app, "J");
676 assert_eq!(app.page, Page::Equalizer);
677 press(&mut app, "K K");
678 assert_eq!(app.page, Page::Settings);
679 press(&mut app, "1");
680 assert_eq!(app.page, Page::Overview);
681 press(&mut app, "g t");
682 assert_eq!(app.page, Page::Equalizer);
683 press(&mut app, "g T");
684 assert_eq!(app.page, Page::Overview);
685 press(&mut app, "9");
686 assert_eq!(app.page, Page::Overview);
687 }
688
689 #[test]
690 fn esc_and_clicks_hide_the_cursor() {
691 let mut app = app();
692 press(&mut app, "j j");
693 assert!(app.focus().is_some());
694 press(&mut app, "Esc");
695 assert_eq!(app.focus(), None);
696 press(&mut app, "j");
697 let _ = app.update(Message::Pointer);
698 assert_eq!(app.focus(), None);
699 // It comes back where it was.
700 press(&mut app, "j");
701 assert_eq!(app.focus(), Some(Item::AncLevel));
702 }
703
704 #[test]
705 fn the_list_of_keys_closes_on_any_key() {
706 let mut app = app();
707 press(&mut app, "?");
708 assert!(app.help);
709 press(&mut app, "j");
710 assert!(!app.help);
711 assert_eq!(app.focus(), None);
712 press(&mut app, "?");
713 let _ = app.update(Message::CloseHelp);
714 assert!(!app.help);
715 }
716
717 #[test]
718 fn steps() {
719 let abc = ['a', 'b', 'c'];
720 assert_eq!(step(&abc, Some(0), Action::Left), None);
721 assert_eq!(step(&abc, Some(0), Action::Right), Some('b'));
722 assert_eq!(step(&abc, Some(2), Action::Right), None);
723 assert_eq!(step(&abc, Some(2), Action::Activate), Some('a'));
724 assert_eq!(step(&abc, None, Action::Left), Some('a'));
725 }
726
727 #[test]
728 fn arguments() {
729 let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned()));
730 assert!(matches!(args(&[]), Ok(Args::Run { theme: None })));
731 assert!(matches!(args(&["--theme", "x.json"]), Ok(Args::Run { theme: Some(_) })));
732 assert!(matches!(args(&["--help"]), Ok(Args::Help)));
733 assert!(args(&["--theme"]).is_err());
734 assert!(args(&["--bogus"]).is_err());
735 }
736
737 /// Renders every page to `target/gui-snapshots/`, so the look can be checked without a
738 /// display. `BUDCTL_THEME=<file>` renders with that palette.
739 #[test]
740 fn pages_render() {
741 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
742 let _ = fs::remove_dir_all(&dir);
743 let font = theme::system_font();
744 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
745 let on = |page: Page, keys: &str| {
746 let mut app = App::new(palette.clone(), font);
747 app.page = page;
748 press(&mut app, keys);
749 app
750 };
751 let mut shots: Vec<(String, f32, App)> = Page::ALL
752 .into_iter()
753 .map(|page| (format!("{page:?}").to_lowercase(), 800.0, on(page, "")))
754 .collect();
755 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
756 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
757 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
758 for (name, height, app) in shots {
759 let settings = iced::Settings {
760 default_font: font,
761 ..iced::Settings::default()
762 };
763 let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
764 let snapshot = ui.snapshot(&app.theme).unwrap();
765 assert!(snapshot.matches_image(dir.join(name)).unwrap());
766 }
767 }
768}