Krolyxon
Commit e8c78b9

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

1394 lines · 50.2 KB · Rust

1//! `budctl-gui`: control wireless earbuds from Linux, in a window.
2//!
3//! A thin layer over budctl-core, like the CLI: `worker.rs` talks to the buds on a thread of its
4//! own, and the window shows what they report. The mouse comes first; vim-like keys work on top
5//! (`keys.rs`). `--demo` shows made-up buds instead.
6
7mod buds;
8mod equalizer;
9mod icons;
10mod keys;
11mod overview;
12mod theme;
13mod ui;
14mod worker;
15
16use std::collections::VecDeque;
17use std::path::PathBuf;
18use std::process::ExitCode;
19use std::sync::mpsc::Sender;
20use std::time::{Duration, Instant};
21
22use budctl_core::Kind;
23use budctl_core::bt::BdAddr;
24use budctl_core::status::{AncLevel, EqBand, NoiseControl, NoiseMode, Switch};
25use iced::keyboard::{self, Key, Modifiers};
26use iced::widget::operation::{self, RelativeOffset};
27use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text};
28use iced::{Alignment, Border, Color, Element, Font, Length, Size, Subscription, Task, Theme, event, mouse, window};
29
30use icons::Icon;
31use keys::Action;
32use theme::Look;
33use worker::{Command, Event, Reading};
34
35const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>] [--demo] [--debug]
36
37--device picks the buds when more than one supported pair is connected.
38--demo shows made-up buds, without Bluetooth.
39--debug hex-dumps every frame sent and received on stderr, as budctl --debug does.
40Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in.
41The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …).
42Press ? in the window for the keys.";
43
44/// The page's scrolling area, so the cursor can be kept in view.
45const SCROLL: &str = "page";
46
47/// When the window gets focus and the last reading is older than this, the buds are read again:
48/// the phone may have changed something meanwhile.
49const STALE: Duration = Duration::from_secs(30);
50
51fn main() -> ExitCode {
52 let options = match parse_args(std::env::args().skip(1)) {
53 Ok(Args::Run(options)) => options,
54 Ok(Args::Help) => {
55 println!("{USAGE}");
56 return ExitCode::SUCCESS;
57 }
58 Err(message) => {
59 eprintln!("budctl-gui: {message}\n{USAGE}");
60 return ExitCode::from(1);
61 }
62 };
63 if options.debug {
64 budctl_core::debug::enable();
65 }
66 let theme_file = options.theme.clone().or_else(theme::default_file);
67 let font = theme::system_font();
68 let boot = move || App::new(theme_file.clone(), font, options.device, options.demo);
69 let result = iced::application(boot, App::update, App::view)
70 .title("budctl")
71 .theme(App::theme)
72 .subscription(App::subscription)
73 .default_font(font)
74 .window(window::Settings {
75 size: Size::new(1040.0, 800.0),
76 min_size: Some(Size::new(760.0, 540.0)),
77 platform_specific: window::settings::PlatformSpecific {
78 application_id: "budctl".to_owned(),
79 ..window::settings::PlatformSpecific::default()
80 },
81 ..window::Settings::default()
82 })
83 .run();
84 match result {
85 Ok(()) => ExitCode::SUCCESS,
86 Err(e) => {
87 eprintln!("budctl-gui: {e}");
88 ExitCode::FAILURE
89 }
90 }
91}
92
93#[derive(Default)]
94struct Options {
95 theme: Option<PathBuf>,
96 device: Option<BdAddr>,
97 demo: bool,
98 debug: bool,
99}
100
101enum Args {
102 Run(Options),
103 Help,
104}
105
106fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
107 let mut options = Options::default();
108 while let Some(arg) = args.next() {
109 match arg.as_str() {
110 "--theme" => options.theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)),
111 "--device" => {
112 let address = args.next().ok_or("--device needs an address")?;
113 options.device = Some(address.parse().map_err(|e| format!("--device: {e}"))?);
114 }
115 "--demo" => options.demo = true,
116 "--debug" => options.debug = true,
117 "-h" | "--help" => return Ok(Args::Help),
118 other => return Err(format!("unknown argument {other:?}")),
119 }
120 }
121 Ok(Args::Run(options))
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125enum Page {
126 Overview,
127 Equalizer,
128 Gestures,
129 Earbuds,
130 Settings,
131}
132
133impl Page {
134 const ALL: [Page; 5] = [
135 Page::Overview,
136 Page::Equalizer,
137 Page::Gestures,
138 Page::Earbuds,
139 Page::Settings,
140 ];
141
142 fn title(self) -> &'static str {
143 match self {
144 Page::Overview => "Overview",
145 Page::Equalizer => "Equalizer",
146 Page::Gestures => "Gestures",
147 Page::Earbuds => "Earbud settings",
148 Page::Settings => "App settings",
149 }
150 }
151
152 fn icon(self) -> Icon {
153 match self {
154 Page::Overview => Icon::Overview,
155 Page::Equalizer => Icon::Equalizer,
156 Page::Gestures => Icon::Gestures,
157 Page::Earbuds => Icon::Earbud,
158 Page::Settings => Icon::Settings,
159 }
160 }
161
162 /// The next page in the sidebar, or the previous one, going round.
163 fn step(self, forward: bool) -> Page {
164 let count = Page::ALL.len();
165 let at = Page::ALL.iter().position(|&page| page == self).unwrap_or(0);
166 let to = if forward { at + 1 } else { at + count - 1 };
167 Page::ALL.get(to % count).copied().unwrap_or(self)
168 }
169}
170
171/// What the keyboard cursor can rest on, and what a write changes.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173enum Item {
174 NoiseMode,
175 AncLevel,
176 /// A switch, by its key.
177 Switch(&'static str),
178 /// A row that opens a page.
179 Open(Page),
180 /// An EQ preset, by its id.
181 Preset(u8),
182 /// One band of a custom EQ preset: the preset's id and the band's frequency.
183 Band(u8, u16),
184}
185
186/// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the
187/// items with a write on its way.
188#[derive(Clone, Copy)]
189struct Marks<'a> {
190 focus: Option<Item>,
191 pending: &'a [Item],
192}
193
194/// Bands of a custom EQ preset moved on the screen and not saved on the buds yet.
195#[derive(Debug, Clone, PartialEq)]
196struct Draft {
197 preset: u8,
198 /// All the preset's bands, as shown.
199 bands: Vec<EqBand>,
200}
201
202/// Where the connection to the buds stands.
203#[derive(Debug, Clone, PartialEq)]
204enum Link {
205 /// Made-up buds (`--demo`): nothing talks to Bluetooth.
206 Demo,
207 /// Looking for the buds, or reading them for the first time.
208 Connecting,
209 /// The buds answered the last command.
210 Live,
211 /// The last command failed.
212 Failed { kind: Kind, message: String },
213}
214
215#[derive(Debug, Clone)]
216enum Message {
217 Open(Page),
218 Noise(NoiseMode),
219 /// A switch's key, and whether to turn it on.
220 Switch(&'static str, bool),
221 /// Select an EQ preset, by its id.
222 Preset(u8),
223 /// Move a band of a custom EQ preset. With `save`, save the preset right away, else when
224 /// [`Message::SaveBands`] comes (the mouse lets go of the slider).
225 Band {
226 preset: u8,
227 hz: u16,
228 gain: i8,
229 save: bool,
230 },
231 /// Save the moved bands of a custom EQ preset.
232 SaveBands(u8),
233 /// Read the buds again.
234 Refresh,
235 Worker(Event),
236 /// The window got focus: the palette file may have changed meanwhile, and so may the buds.
237 Focused,
238 Key(Key, Modifiers),
239 /// A mouse button went down: the mouse is in use, so the keyboard cursor hides.
240 Pointer,
241 CloseHelp,
242}
243
244struct App {
245 page: Page,
246 buds: buds::Buds,
247 /// The level "Noise cancelling" turns on at: the one last reported or picked.
248 anc_level: AncLevel,
249 look: Look,
250 theme: Theme,
251 palette: theme::Source,
252 keys: keys::Keys,
253 /// Where the keyboard cursor is, among [`App::items`].
254 cursor: usize,
255 /// Whether it shows. Only keys show it; a click hides it.
256 cursor_shown: bool,
257 /// Whether the list of keys is open.
258 help: bool,
259 /// `--device`, for the worker.
260 device: Option<BdAddr>,
261 link: Link,
262 /// Where commands go, once the worker is up.
263 worker: Option<Sender<Command>>,
264 /// The commands sent and not answered yet, oldest first: the item each one writes, or `None`
265 /// for a read. The worker answers them in order.
266 in_flight: VecDeque<Option<Item>>,
267 /// When the buds were last read.
268 read_at: Option<Instant>,
269 /// Bands moved and not saved yet.
270 draft: Option<Draft>,
271}
272
273impl App {
274 fn new(theme_file: Option<PathBuf>, font: Font, device: Option<BdAddr>, demo: bool) -> Self {
275 let buds = if demo { buds::demo() } else { buds::unknown() };
276 let anc_level = buds
277 .status
278 .noise_control
279 .as_ref()
280 .and_then(|now| now.level.as_deref())
281 .and_then(anc_level)
282 .unwrap_or(AncLevel::Deep);
283 let mut app = App {
284 page: Page::Overview,
285 buds,
286 anc_level,
287 look: Look {
288 p: theme::BUILT_IN,
289 font,
290 },
291 theme: theme::iced_theme(&theme::BUILT_IN),
292 palette: theme::Source::new(theme_file),
293 keys: keys::Keys::default(),
294 cursor: 0,
295 cursor_shown: false,
296 help: false,
297 device,
298 link: if demo { Link::Demo } else { Link::Connecting },
299 worker: None,
300 in_flight: VecDeque::new(),
301 read_at: None,
302 draft: None,
303 };
304 app.refresh_palette();
305 app
306 }
307
308 fn update(&mut self, message: Message) -> Task<Message> {
309 match message {
310 Message::Open(page) => return self.open(page),
311 Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)),
312 Message::Switch(key, on) => self.write(Item::Switch(key), Command::Switch(key, on)),
313 Message::Preset(id) => self.select(id),
314 Message::Band { preset, hz, gain, save } => {
315 self.move_band(preset, hz, gain);
316 if save {
317 self.save_bands(preset);
318 }
319 }
320 Message::SaveBands(preset) => self.save_bands(preset),
321 Message::Refresh => self.refresh(),
322 Message::Worker(event) => self.answer(event),
323 Message::Focused => {
324 self.refresh_palette();
325 if self.read_at.is_some_and(|at| at.elapsed() >= STALE) && self.in_flight.is_empty() {
326 self.refresh();
327 }
328 }
329 Message::Key(key, modifiers) => return self.key(&key, modifiers),
330 Message::Pointer => self.cursor_shown = false,
331 Message::CloseHelp => self.help = false,
332 }
333 Task::none()
334 }
335
336 /// Changes a setting: at once with the made-up buds, else through the worker. Until the buds
337 /// answer, the item shows that it is saving and takes no other write.
338 fn write(&mut self, item: Item, command: Command) {
339 if self.link == Link::Demo {
340 match command {
341 Command::Noise(mode) => self.apply_noise(NoiseControl {
342 mode: mode.name().to_owned(),
343 level: match mode {
344 NoiseMode::Anc(level) => Some(level.name().to_owned()),
345 NoiseMode::Off | NoiseMode::Transparency => None,
346 },
347 }),
348 Command::Switch(key, on) => {
349 if let Some(switch) = self.buds.status.switches.iter().find(|s| s.key == key) {
350 let switch = Switch { on, ..switch.clone() };
351 self.apply_switch(switch);
352 }
353 }
354 Command::Eq(preset) => self.buds.set_eq(preset),
355 Command::CustomEq(_, bands) => {
356 if let Some(preset) = self.buds.preset(item_preset(item)) {
357 let mut preset = preset.clone();
358 for band in &bands {
359 if let Some(shown) = preset.bands.iter_mut().find(|b| b.hz == band.hz) {
360 shown.gain = band.gain;
361 }
362 }
363 self.buds.set_custom(preset);
364 }
365 self.draft = None;
366 }
367 Command::Read => {}
368 }
369 } else if !self.pending().contains(&item) {
370 self.send(command, Some(item));
371 }
372 }
373
374 /// Reads the buds again; with the made-up buds, starts them over.
375 fn refresh(&mut self) {
376 match self.link {
377 Link::Demo => {
378 self.buds = buds::demo();
379 self.draft = None;
380 }
381 _ if self.in_flight.contains(&None) => {}
382 _ => {
383 if self.read_at.is_none() {
384 self.link = Link::Connecting;
385 }
386 self.send(Command::Read, None);
387 }
388 }
389 }
390
391 fn send(&mut self, command: Command, item: Option<Item>) {
392 let Some(worker) = &self.worker else {
393 return;
394 };
395 if worker.send(command).is_ok() {
396 self.in_flight.push_back(item);
397 }
398 }
399
400 /// Takes in what the worker reports.
401 fn answer(&mut self, event: Event) {
402 match event {
403 Event::Ready(worker) => {
404 self.worker = Some(worker);
405 self.send(Command::Read, None);
406 }
407 Event::Found(name) => self.buds.name = name,
408 Event::Lost(message) => {
409 self.buds.connected = false;
410 self.link = Link::Failed {
411 kind: Kind::NotFound,
412 message,
413 };
414 }
415 Event::Read(reading) => {
416 self.in_flight.pop_front();
417 self.apply_reading(*reading);
418 }
419 Event::Noise(now) => {
420 self.in_flight.pop_front();
421 self.apply_noise(now);
422 self.link = Link::Live;
423 }
424 Event::Switch(switch) => {
425 self.in_flight.pop_front();
426 self.apply_switch(switch);
427 self.link = Link::Live;
428 }
429 Event::Eq(now) => {
430 self.in_flight.pop_front();
431 self.buds.set_eq(now);
432 self.link = Link::Live;
433 }
434 Event::CustomEq(preset) => {
435 self.in_flight.pop_front();
436 let id = preset.id;
437 self.buds.set_custom(preset);
438 self.link = Link::Live;
439 // Bands moved while this write was on its way go out now.
440 match &self.draft {
441 Some(draft) if draft.preset == id => {
442 if self.buds.preset(id).is_some_and(|saved| saved.bands == draft.bands) {
443 self.draft = None;
444 } else {
445 self.save_bands(id);
446 }
447 }
448 _ => {}
449 }
450 }
451 Event::Failed { kind, message } => {
452 if let Some(Some(Item::Preset(id))) = self.in_flight.pop_front() {
453 // The buds keep what they had: show that, not the moved bands.
454 if self.draft.as_ref().is_some_and(|draft| draft.preset == id) {
455 self.draft = None;
456 }
457 }
458 if kind == Kind::NotFound {
459 self.buds.connected = false;
460 }
461 self.link = Link::Failed { kind, message };
462 }
463 }
464 }
465
466 fn apply_reading(&mut self, reading: Reading) {
467 self.buds.eq = reading.eq;
468 if let Some(level) = reading
469 .status
470 .noise_control
471 .as_ref()
472 .and_then(|now| now.level.as_deref())
473 .and_then(anc_level)
474 {
475 self.anc_level = level;
476 }
477 self.buds.status = reading.status;
478 self.buds.connected = true;
479 self.link = Link::Live;
480 self.read_at = Some(Instant::now());
481 }
482
483 fn apply_noise(&mut self, now: NoiseControl) {
484 if let Some(level) = now.level.as_deref().and_then(anc_level) {
485 self.anc_level = level;
486 }
487 self.buds.status.noise_control = Some(now);
488 }
489
490 fn apply_switch(&mut self, switch: Switch) {
491 if let Some(shown) = self.buds.status.switches.iter_mut().find(|s| s.key == switch.key) {
492 *shown = switch;
493 }
494 }
495
496 /// Selects an EQ preset: a built-in one by name, a custom one by saving it unchanged. The
497 /// preset in use is left alone.
498 fn select(&mut self, id: u8) {
499 let Some(preset) = self.buds.preset(id) else {
500 return;
501 };
502 if self.buds.status.eq.as_deref() == Some(buds::key(preset).as_str()) {
503 return;
504 }
505 let command = if preset.custom {
506 Command::CustomEq(buds::key(preset), Vec::new())
507 } else {
508 Command::Eq(preset.name.clone())
509 };
510 self.write(Item::Preset(id), command);
511 }
512
513 /// Moves a band on the screen, within the preset's range. Nothing goes to the buds yet.
514 fn move_band(&mut self, id: u8, hz: u16, gain: i8) {
515 let Some(preset) = self.buds.preset(id) else {
516 return;
517 };
518 let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX));
519 let mut bands = match self.draft.take() {
520 Some(draft) if draft.preset == id => draft.bands,
521 _ => preset.bands.clone(),
522 };
523 if let Some(band) = bands.iter_mut().find(|band| band.hz == hz) {
524 band.gain = gain.clamp(low, high);
525 }
526 self.draft = Some(Draft { preset: id, bands });
527 }
528
529 /// Saves the moved bands of a custom preset. If a write to it is on its way, they go out
530 /// when it is answered: the latest bands win.
531 fn save_bands(&mut self, id: u8) {
532 let Some(draft) = self.draft.clone().filter(|draft| draft.preset == id) else {
533 return;
534 };
535 let Some(preset) = self.buds.preset(id) else {
536 return;
537 };
538 if !self.pending().contains(&Item::Preset(id)) {
539 self.write(Item::Preset(id), Command::CustomEq(buds::key(preset), draft.bands));
540 }
541 }
542
543 /// The items with a write on its way.
544 fn pending(&self) -> Vec<Item> {
545 self.in_flight.iter().flatten().copied().collect()
546 }
547
548 /// Shows `page` from its top, with the cursor on its first item.
549 fn open(&mut self, page: Page) -> Task<Message> {
550 self.page = page;
551 self.cursor = 0;
552 operation::snap_to(SCROLL, RelativeOffset::START)
553 }
554
555 fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> {
556 let Some(action) = self.keys.press(key, modifiers) else {
557 return Task::none();
558 };
559 if self.help {
560 // The list of keys is in front of everything: a key only closes it.
561 self.help = false;
562 return Task::none();
563 }
564 match action {
565 Action::Quit => iced::exit(),
566 Action::Help => {
567 self.help = true;
568 Task::none()
569 }
570 Action::Escape => {
571 self.cursor_shown = false;
572 Task::none()
573 }
574 Action::Refresh => {
575 self.refresh();
576 Task::none()
577 }
578 Action::NextPage => self.open(self.page.step(true)),
579 Action::PreviousPage => self.open(self.page.step(false)),
580 Action::Page(n) => match Page::ALL.get(n) {
581 Some(&page) => self.open(page),
582 None => Task::none(),
583 },
584 Action::Down
585 | Action::Up
586 | Action::First
587 | Action::Last
588 | Action::Left
589 | Action::Right
590 | Action::Activate => self.on_cursor(action),
591 }
592 }
593
594 /// What the current page offers the keyboard cursor, top to bottom.
595 fn items(&self) -> Vec<Item> {
596 match self.page {
597 Page::Overview if self.has_reading() => overview::items(&self.buds),
598 Page::Equalizer if self.has_reading() => equalizer::items(&self.buds),
599 _ => Vec::new(),
600 }
601 }
602
603 /// Whether there is anything to show: the made-up buds, or a reading of the real ones.
604 fn has_reading(&self) -> bool {
605 self.link == Link::Demo || self.read_at.is_some()
606 }
607
608 /// The item under the cursor, when the cursor shows.
609 fn focus(&self) -> Option<Item> {
610 let items = self.items();
611 let last = items.len().checked_sub(1)?;
612 self.cursor_shown.then(|| items.get(self.cursor.min(last)).copied())?
613 }
614
615 fn on_cursor(&mut self, action: Action) -> Task<Message> {
616 let items = self.items();
617 let Some(last) = items.len().checked_sub(1) else {
618 return Task::none();
619 };
620 self.cursor = self.cursor.min(last);
621 if !self.cursor_shown {
622 // The first key only shows the cursor, so nothing changes where it cannot be seen.
623 self.cursor_shown = true;
624 return self.follow();
625 }
626 match action {
627 Action::Down => self.cursor = (self.cursor + 1).min(last),
628 Action::Up => self.cursor = self.cursor.saturating_sub(1),
629 Action::First => self.cursor = 0,
630 Action::Last => self.cursor = last,
631 _ => {
632 let change = items.get(self.cursor).and_then(|&item| self.change(item, action));
633 return match change {
634 Some(message) => self.update(message),
635 None => Task::none(),
636 };
637 }
638 }
639 self.follow()
640 }
641
642 /// What `action` (`h`, `l` or Enter) does to `item`, if anything.
643 fn change(&self, item: Item, action: Action) -> Option<Message> {
644 match item {
645 Item::NoiseMode => {
646 let modes = [NoiseMode::Off, NoiseMode::Anc(self.anc_level), NoiseMode::Transparency];
647 let now = self.buds.status.noise_control.as_ref()?;
648 let at = modes.iter().position(|mode| mode.name() == now.mode);
649 step(&modes, at, action).map(Message::Noise)
650 }
651 Item::AncLevel => {
652 let levels = [AncLevel::Light, AncLevel::Medium, AncLevel::Deep];
653 let at = levels.iter().position(|&level| level == self.anc_level);
654 step(&levels, at, action).map(|level| Message::Noise(NoiseMode::Anc(level)))
655 }
656 Item::Switch(key) => {
657 let on = self.buds.status.switches.iter().find(|s| s.key == key)?.on;
658 let want = match action {
659 Action::Left => false,
660 Action::Right => true,
661 _ => !on,
662 };
663 (want != on).then_some(Message::Switch(key, want))
664 }
665 Item::Open(page) => (action != Action::Left).then_some(Message::Open(page)),
666 Item::Preset(id) => {
667 let chosen = self.buds.status.eq.as_deref() == self.buds.preset(id).map(buds::key).as_deref();
668 (action != Action::Left && !chosen).then_some(Message::Preset(id))
669 }
670 Item::Band(id, hz) => {
671 let preset = self.buds.preset(id)?;
672 let gain = match &self.draft {
673 Some(draft) if draft.preset == id => &draft.bands,
674 _ => &preset.bands,
675 }
676 .iter()
677 .find(|band| band.hz == hz)?
678 .gain;
679 let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX));
680 let to = match action {
681 Action::Left => gain.checked_sub(1)?.max(low),
682 Action::Right => gain.checked_add(1)?.min(high),
683 _ => return None,
684 };
685 (to != gain).then_some(Message::Band {
686 preset: id,
687 hz,
688 gain: to,
689 save: true,
690 })
691 }
692 }
693 }
694
695 /// Scrolls the page so the cursor stays in view: the first item at the top, the last at the
696 /// bottom, the others in between.
697 fn follow(&self) -> Task<Message> {
698 let count = self.items().len();
699 if !self.cursor_shown || count < 2 {
700 return Task::none();
701 }
702 let y = self.cursor as f32 / (count - 1) as f32;
703 operation::snap_to(SCROLL, RelativeOffset { x: 0.0, y })
704 }
705
706 fn refresh_palette(&mut self) {
707 let palette = match self.palette.refresh() {
708 None => return,
709 Some(Ok(palette)) => palette,
710 Some(Err(e)) => {
711 eprintln!("budctl-gui: {e}; using the built-in colors");
712 theme::BUILT_IN
713 }
714 };
715 self.look.p = palette;
716 self.theme = theme::iced_theme(&palette);
717 }
718
719 fn theme(&self) -> Theme {
720 self.theme.clone()
721 }
722
723 fn subscription(&self) -> Subscription<Message> {
724 let events = event::listen_with(|event, _status, _window| match event {
725 iced::Event::Window(window::Event::Focused) => Some(Message::Focused),
726 iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
727 Some(Message::Key(key, modifiers))
728 }
729 iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer),
730 _ => None,
731 });
732 if self.link == Link::Demo {
733 return events;
734 }
735 let worker = Subscription::run_with(self.device, worker::run).map(Message::Worker);
736 Subscription::batch([events, worker])
737 }
738
739 fn view(&self) -> Element<'_, Message> {
740 let look = self.look;
741 let pending = self.pending();
742 let page = match self.page {
743 Page::Overview if self.has_reading() => {
744 let marks = Marks {
745 focus: self.focus(),
746 pending: &pending,
747 };
748 overview::view(look, &self.buds, self.anc_level, marks)
749 }
750 Page::Overview => self.connecting(),
751 Page::Equalizer if self.has_reading() => {
752 let marks = Marks {
753 focus: self.focus(),
754 pending: &pending,
755 };
756 equalizer::view(look, &self.buds, self.draft.as_ref(), marks)
757 }
758 page => column![
759 ui::title(look, page.title()),
760 text("This page comes in a later step.").size(14).color(look.p.dim)
761 ]
762 .spacing(12)
763 .into(),
764 };
765 let page = column![self.problem(), page].spacing(18);
766 let p = look.p;
767 let content = scrollable(
768 container(container(page).max_width(860))
769 .center_x(Length::Fill)
770 .padding([30, 36]),
771 )
772 .id(SCROLL)
773 .height(Length::Fill)
774 .style(move |theme, status| {
775 let mut style = scrollable::default(theme, status);
776 for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
777 rail.background = None;
778 rail.border = Border::default();
779 rail.scroller.background = p.surface_highest.into();
780 rail.scroller.border = Border::default().rounded(4);
781 }
782 style
783 });
784 let window = row![self.sidebar(), ui::divider(look), content];
785 if self.help {
786 stack![window, self.help()].into()
787 } else {
788 window.into()
789 }
790 }
791
792 /// The Overview before the first reading: connecting, or why it did not work.
793 fn connecting(&self) -> Element<'_, Message> {
794 let look = self.look;
795 let p = look.p;
796 let (headline, detail) = match &self.link {
797 Link::Failed {
798 kind: Kind::NotFound,
799 message,
800 } => ("Your buds are not connected", message.as_str()),
801 Link::Failed {
802 kind: Kind::Busy,
803 message,
804 } => ("The control channel is busy", message.as_str()),
805 Link::Failed { message, .. } => ("Something went wrong", message.as_str()),
806 _ => (
807 "Connecting to your buds…",
808 "budctl looks for the paired buds it supports and reads them.",
809 ),
810 };
811 let retry = matches!(self.link, Link::Failed { .. })
812 .then(|| container(ui::outline_button(look, "Try again", Message::Refresh)).width(180));
813 let words = column![
814 text(headline).size(20).font(look.bold()).color(p.fg),
815 text(detail).size(14).color(p.dim)
816 ]
817 .spacing(8);
818 column![
819 ui::title(look, "Overview"),
820 ui::panel(look, column![words].push(retry).spacing(18).into())
821 ]
822 .spacing(18)
823 .into()
824 }
825
826 /// A failure after the buds were read once: what went wrong, above the page.
827 fn problem(&self) -> Option<Element<'_, Message>> {
828 let Link::Failed { message, .. } = &self.link else {
829 return None;
830 };
831 // Before the first reading, the Overview says it already.
832 self.read_at?;
833 let look = self.look;
834 let p = look.p;
835 let content = row![
836 text(message.as_str()).size(14).color(p.err).width(Length::Fill),
837 container(ui::outline_button(look, "Try again", Message::Refresh)).width(140)
838 ]
839 .spacing(16)
840 .align_y(Alignment::Center);
841 Some(
842 container(content)
843 .padding([12, 16])
844 .width(Length::Fill)
845 .style(move |_| container::Style {
846 background: Some(p.surface.into()),
847 border: Border {
848 color: p.err,
849 width: 1.0,
850 radius: 14.0.into(),
851 },
852 ..container::Style::default()
853 })
854 .into(),
855 )
856 }
857
858 fn sidebar(&self) -> Element<'_, Message> {
859 let look = self.look;
860 let p = look.p;
861 let (light, state) = match &self.link {
862 Link::Demo | Link::Live => (p.ok, "Connected"),
863 Link::Connecting => (p.dim, "Connecting…"),
864 Link::Failed { kind: Kind::Busy, .. } => (p.err, "Busy"),
865 Link::Failed { .. } if !self.buds.connected => (p.dim, "Not connected"),
866 Link::Failed { .. } => (p.err, "Error"),
867 };
868 let device = container(
869 column![
870 text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg),
871 row![ui::light(light), text(state).size(13).color(p.dim), space::horizontal()]
872 .push((self.link == Link::Demo).then(|| ui::tag(look, "demo")))
873 .spacing(8)
874 .align_y(Alignment::Center),
875 ui::outline_button(look, "Refresh", Message::Refresh),
876 ]
877 .spacing(12),
878 )
879 .padding(16)
880 .style(move |_| container::Style {
881 background: Some(p.surface.into()),
882 border: Border {
883 color: p.line,
884 width: 1.0,
885 radius: 18.0.into(),
886 },
887 ..container::Style::default()
888 });
889 let pages = Page::ALL.into_iter().fold(Column::new().spacing(3), |pages, page| {
890 pages.push(ui::nav_item(
891 look,
892 page.icon(),
893 page.title(),
894 page == self.page,
895 Message::Open(page),
896 ))
897 });
898 let hint = text("Press ? for keys").size(12.5).color(p.dim);
899 container(column![device, pages, space::vertical(), hint].spacing(22))
900 .padding([18, 14])
901 .width(268)
902 .height(Length::Fill)
903 .style(move |_| container::Style {
904 background: Some(p.surface_low.into()),
905 ..container::Style::default()
906 })
907 .into()
908 }
909
910 /// The list of keys, over a dimmed window. A key or a click closes it.
911 fn help(&self) -> Element<'_, Message> {
912 let look = self.look;
913 let p = look.p;
914 let rows = keys::HELP.iter().fold(Column::new().spacing(9), |rows, &(keys, what)| {
915 rows.push(row![
916 text(keys).size(14).font(look.medium()).color(p.fg).width(170),
917 text(what).size(14).color(p.dim)
918 ])
919 });
920 let list = ui::panel(look, column![ui::title(look, "Keys"), rows].spacing(18).into());
921 let backdrop = container(container(list).width(560))
922 .center(Length::Fill)
923 .style(|_| container::Style {
924 background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
925 ..container::Style::default()
926 });
927 opaque(mouse_area(backdrop).on_press(Message::CloseHelp))
928 }
929}
930
931/// The choice `action` moves to from the one at `at`: `h` and `l` stop at the ends, Enter goes
932/// round. With nothing chosen, any of them picks the first.
933fn step<T: Copy>(choices: &[T], at: Option<usize>, action: Action) -> Option<T> {
934 let to = match (action, at) {
935 (_, None) => 0,
936 (Action::Left, Some(at)) => at.checked_sub(1)?,
937 (Action::Right, Some(at)) => at + 1,
938 (Action::Activate, Some(at)) => (at + 1).checked_rem(choices.len())?,
939 _ => return None,
940 };
941 choices.get(to).copied()
942}
943
944/// The level a [`NoiseControl::level`] names.
945fn anc_level(name: &str) -> Option<AncLevel> {
946 [AncLevel::Light, AncLevel::Medium, AncLevel::Deep]
947 .into_iter()
948 .find(|level| level.name() == name)
949}
950
951/// The preset a write to `item` is for.
952fn item_preset(item: Item) -> u8 {
953 match item {
954 Item::Preset(id) | Item::Band(id, _) => id,
955 _ => 0,
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use iced::keyboard::key::Named;
963 use std::fs;
964 use std::path::Path;
965 use std::sync::mpsc::{self, Receiver};
966
967 /// With the made-up buds.
968 fn app() -> App {
969 App::new(None, Font::DEFAULT, None, true)
970 }
971
972 /// Talking to a worker that the test plays: the receiver gets what the app sends it.
973 fn live() -> (App, Receiver<Command>) {
974 let mut app = App::new(None, Font::DEFAULT, None, false);
975 let (worker, commands) = mpsc::channel();
976 let _ = app.update(Message::Worker(Event::Ready(worker)));
977 (app, commands)
978 }
979
980 fn reading() -> Reading {
981 Reading {
982 status: buds::demo().status,
983 eq: buds::demo().eq,
984 }
985 }
986
987 /// Presses keys, given by name and separated by spaces: `j`, `G`, `Enter`, `Esc`.
988 fn press(app: &mut App, keys: &str) {
989 for name in keys.split_whitespace() {
990 let (key, modifiers) = match name {
991 "Enter" => (Key::Named(Named::Enter), Modifiers::empty()),
992 "Esc" => (Key::Named(Named::Escape), Modifiers::empty()),
993 c if c.chars().all(char::is_uppercase) => (Key::Character(c.into()), Modifiers::SHIFT),
994 c => (Key::Character(c.into()), Modifiers::empty()),
995 };
996 let _ = app.update(Message::Key(key, modifiers));
997 }
998 }
999
1000 fn mode(app: &App) -> &str {
1001 &app.buds.status.noise_control.as_ref().unwrap().mode
1002 }
1003
1004 fn switch(app: &App, key: &str) -> bool {
1005 app.buds.status.switches.iter().find(|s| s.key == key).unwrap().on
1006 }
1007
1008 #[test]
1009 fn noise_cancelling_remembers_its_level() {
1010 let mut app = app();
1011 let _ = app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
1012 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1013 assert_eq!(app.anc_level, AncLevel::Light);
1014 let now = app.buds.status.noise_control.as_ref().unwrap();
1015 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None));
1016 let _ = app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
1017 let now = app.buds.status.noise_control.as_ref().unwrap();
1018 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
1019 }
1020
1021 #[test]
1022 fn switches_change_by_key() {
1023 let mut app = app();
1024 let _ = app.update(Message::Switch("game", true));
1025 assert!(switch(&app, "game"));
1026 }
1027
1028 #[test]
1029 fn the_overview_items_in_order() {
1030 let mut app = app();
1031 assert_eq!(
1032 app.items(),
1033 [
1034 Item::NoiseMode,
1035 Item::AncLevel,
1036 Item::Switch("game"),
1037 Item::Switch("spatial"),
1038 Item::Switch("bass-boost"),
1039 Item::Switch("wind"),
1040 Item::Switch("dual"),
1041 Item::Open(Page::Equalizer),
1042 Item::Open(Page::Gestures),
1043 Item::Open(Page::Earbuds),
1044 ]
1045 );
1046 let _ = app.update(Message::Noise(NoiseMode::Off));
1047 assert!(!app.items().contains(&Item::AncLevel));
1048 }
1049
1050 #[test]
1051 fn the_first_key_only_shows_the_cursor() {
1052 let mut app = app();
1053 assert_eq!(app.focus(), None);
1054 press(&mut app, "l");
1055 assert_eq!(app.focus(), Some(Item::NoiseMode));
1056 assert_eq!(mode(&app), "anc");
1057 }
1058
1059 #[test]
1060 fn h_and_l_step_through_choices_and_enter_goes_round() {
1061 let mut app = app();
1062 press(&mut app, "j l");
1063 assert_eq!(mode(&app), "transparency");
1064 press(&mut app, "l");
1065 assert_eq!(mode(&app), "transparency");
1066 press(&mut app, "h h h");
1067 assert_eq!(mode(&app), "off");
1068 press(&mut app, "Enter Enter");
1069 assert_eq!(mode(&app), "transparency");
1070 press(&mut app, "Enter");
1071 assert_eq!(mode(&app), "off");
1072 // On the strength: deep at first.
1073 press(&mut app, "l j");
1074 assert_eq!(app.focus(), Some(Item::AncLevel));
1075 press(&mut app, "h h h");
1076 assert_eq!(app.anc_level, AncLevel::Light);
1077 press(&mut app, "Enter");
1078 assert_eq!(app.anc_level, AncLevel::Medium);
1079 assert_eq!(
1080 app.buds.status.noise_control.as_ref().unwrap().level.as_deref(),
1081 Some("medium")
1082 );
1083 }
1084
1085 #[test]
1086 fn switches_take_h_l_and_enter() {
1087 let mut app = app();
1088 press(&mut app, "j j j");
1089 assert_eq!(app.focus(), Some(Item::Switch("game")));
1090 press(&mut app, "l");
1091 assert!(switch(&app, "game"));
1092 press(&mut app, "l");
1093 assert!(switch(&app, "game"));
1094 press(&mut app, "h");
1095 assert!(!switch(&app, "game"));
1096 press(&mut app, "Enter");
1097 assert!(switch(&app, "game"));
1098 }
1099
1100 #[test]
1101 fn moving_stops_at_the_ends() {
1102 let mut app = app();
1103 press(&mut app, "k k");
1104 assert_eq!(app.focus(), Some(Item::NoiseMode));
1105 press(&mut app, "G j j");
1106 assert_eq!(app.focus(), Some(Item::Open(Page::Earbuds)));
1107 press(&mut app, "g g");
1108 assert_eq!(app.focus(), Some(Item::NoiseMode));
1109 }
1110
1111 #[test]
1112 fn rows_open_pages_and_the_cursor_starts_over() {
1113 let mut app = app();
1114 press(&mut app, "G G h");
1115 assert_eq!(app.page, Page::Overview);
1116 press(&mut app, "l");
1117 assert_eq!(app.page, Page::Earbuds);
1118 assert_eq!(app.cursor, 0);
1119 assert!(app.cursor_shown);
1120 }
1121
1122 #[test]
1123 fn pages_by_key() {
1124 let mut app = app();
1125 press(&mut app, "J");
1126 assert_eq!(app.page, Page::Equalizer);
1127 press(&mut app, "K K");
1128 assert_eq!(app.page, Page::Settings);
1129 press(&mut app, "1");
1130 assert_eq!(app.page, Page::Overview);
1131 press(&mut app, "g t");
1132 assert_eq!(app.page, Page::Equalizer);
1133 press(&mut app, "g T");
1134 assert_eq!(app.page, Page::Overview);
1135 press(&mut app, "9");
1136 assert_eq!(app.page, Page::Overview);
1137 }
1138
1139 #[test]
1140 fn esc_and_clicks_hide_the_cursor() {
1141 let mut app = app();
1142 press(&mut app, "j j");
1143 assert!(app.focus().is_some());
1144 press(&mut app, "Esc");
1145 assert_eq!(app.focus(), None);
1146 press(&mut app, "j");
1147 let _ = app.update(Message::Pointer);
1148 assert_eq!(app.focus(), None);
1149 // It comes back where it was.
1150 press(&mut app, "j");
1151 assert_eq!(app.focus(), Some(Item::AncLevel));
1152 }
1153
1154 #[test]
1155 fn the_list_of_keys_closes_on_any_key() {
1156 let mut app = app();
1157 press(&mut app, "?");
1158 assert!(app.help);
1159 press(&mut app, "j");
1160 assert!(!app.help);
1161 assert_eq!(app.focus(), None);
1162 press(&mut app, "?");
1163 let _ = app.update(Message::CloseHelp);
1164 assert!(!app.help);
1165 }
1166
1167 #[test]
1168 fn live_buds_are_read_first() {
1169 let (mut app, commands) = live();
1170 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1171 assert_eq!(app.link, Link::Connecting);
1172 assert!(app.items().is_empty(), "nothing to move over before the first reading");
1173 // A second refresh while the read is on its way sends nothing.
1174 press(&mut app, "r");
1175 assert!(commands.try_recv().is_err());
1176
1177 let _ = app.update(Message::Worker(Event::Found("realme Buds T310".to_owned())));
1178 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1179 assert_eq!(app.link, Link::Live);
1180 assert_eq!(app.buds.name, "realme Buds T310");
1181 assert!(app.buds.connected);
1182 assert_eq!(app.items().len(), 10);
1183 }
1184
1185 #[test]
1186 fn the_preset_in_use_is_not_written_again() {
1187 let (mut app, commands) = live();
1188 let _ = commands.try_recv();
1189 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1190 let _ = app.update(Message::Preset(4));
1191 assert!(commands.try_recv().is_err(), "Custom1 is in use already");
1192 let _ = app.update(Message::Preset(3));
1193 assert!(matches!(commands.try_recv(), Ok(Command::Eq(name)) if name == "pure-bass"));
1194 }
1195
1196 #[test]
1197 fn live_writes_wait_for_the_buds() {
1198 let (mut app, commands) = live();
1199 let _ = commands.try_recv();
1200 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1201
1202 let _ = app.update(Message::Switch("game", true));
1203 assert!(matches!(commands.try_recv(), Ok(Command::Switch("game", true))));
1204 assert!(!switch(&app, "game"), "the switch changes when the buds say so");
1205 assert_eq!(app.pending(), [Item::Switch("game")]);
1206 // While it is on its way, the same switch takes no other write.
1207 press(&mut app, "j j j Enter");
1208 assert!(commands.try_recv().is_err());
1209
1210 let game = Switch {
1211 key: "game",
1212 label: "Game mode",
1213 on: true,
1214 };
1215 let _ = app.update(Message::Worker(Event::Switch(game)));
1216 assert!(switch(&app, "game"));
1217 assert!(app.pending().is_empty());
1218
1219 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1220 assert!(matches!(
1221 commands.try_recv(),
1222 Ok(Command::Noise(NoiseMode::Transparency))
1223 ));
1224 assert_eq!(mode(&app), "anc");
1225 let now = NoiseControl {
1226 mode: "transparency".to_owned(),
1227 level: None,
1228 };
1229 let _ = app.update(Message::Worker(Event::Noise(now)));
1230 assert_eq!(mode(&app), "transparency");
1231 }
1232
1233 #[test]
1234 fn failures_say_why_and_clear_the_write() {
1235 let (mut app, commands) = live();
1236 let _ = app.update(Message::Worker(Event::Failed {
1237 kind: Kind::Busy,
1238 message: "control channel busy".to_owned(),
1239 }));
1240 assert!(matches!(app.link, Link::Failed { kind: Kind::Busy, .. }));
1241 assert!(app.problem().is_none(), "the Overview says it, not a banner");
1242
1243 let _ = commands.try_recv();
1244 let _ = app.update(Message::Refresh);
1245 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1246 assert_eq!(app.link, Link::Connecting);
1247 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1248
1249 let _ = app.update(Message::Switch("dual", false));
1250 let _ = app.update(Message::Worker(Event::Failed {
1251 kind: Kind::NotFound,
1252 message: "the buds closed the control link".to_owned(),
1253 }));
1254 assert!(app.pending().is_empty());
1255 assert!(switch(&app, "dual"));
1256 assert!(!app.buds.connected);
1257 assert!(app.problem().is_some());
1258 }
1259
1260 #[test]
1261 fn presets_and_bands_by_key() {
1262 let mut app = app();
1263 press(&mut app, "J");
1264 assert_eq!(app.page, Page::Equalizer);
1265 press(&mut app, "j l");
1266 assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
1267 assert_eq!(app.buds.eq_name(), "Pure raw");
1268 press(&mut app, "G");
1269 assert_eq!(app.focus(), Some(Item::Band(4, 16000)));
1270 // Moving a band saves the custom preset, which also selects it.
1271 press(&mut app, "l");
1272 assert_eq!(app.buds.status.eq.as_deref(), Some("custom-4"));
1273 let last = |app: &App| app.buds.preset(4).unwrap().bands.last().unwrap().gain;
1274 assert_eq!(last(&app), 3);
1275 press(&mut app, "l l l l l");
1276 assert_eq!(last(&app), 6, "it stops at the top of the range");
1277 }
1278
1279 #[test]
1280 fn band_moves_wait_for_the_write_before_them() {
1281 let (mut app, commands) = live();
1282 let _ = commands.try_recv();
1283 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1284 press(&mut app, "J G G l");
1285 let gain = |bands: &[EqBand]| bands.last().map(|band| band.gain);
1286 assert!(matches!(
1287 commands.try_recv(),
1288 Ok(Command::CustomEq(name, bands)) if name == "custom-4" && gain(&bands) == Some(3)
1289 ));
1290 // While that write is on its way, moves only change the screen.
1291 press(&mut app, "l l");
1292 assert!(commands.try_recv().is_err());
1293 assert_eq!(app.draft.as_ref().and_then(|draft| gain(&draft.bands)), Some(5));
1294 // The answer comes: the latest bands go out at once.
1295 let mut saved = buds::demo().preset(4).unwrap().clone();
1296 if let Some(band) = saved.bands.last_mut() {
1297 band.gain = 3;
1298 }
1299 let _ = app.update(Message::Worker(Event::CustomEq(saved.clone())));
1300 assert!(matches!(
1301 commands.try_recv(),
1302 Ok(Command::CustomEq(_, bands)) if gain(&bands) == Some(5)
1303 ));
1304 assert_eq!(app.pending(), [Item::Preset(4)]);
1305 // A failure drops the moves: the screen shows what the buds kept.
1306 let _ = app.update(Message::Worker(Event::Failed {
1307 kind: Kind::Failed,
1308 message: "no reply".to_owned(),
1309 }));
1310 assert_eq!(app.draft, None);
1311 assert_eq!(gain(&app.buds.preset(4).unwrap().bands), Some(3));
1312 }
1313
1314 #[test]
1315 fn steps() {
1316 let abc = ['a', 'b', 'c'];
1317 assert_eq!(step(&abc, Some(0), Action::Left), None);
1318 assert_eq!(step(&abc, Some(0), Action::Right), Some('b'));
1319 assert_eq!(step(&abc, Some(2), Action::Right), None);
1320 assert_eq!(step(&abc, Some(2), Action::Activate), Some('a'));
1321 assert_eq!(step(&abc, None, Action::Left), Some('a'));
1322 }
1323
1324 #[test]
1325 fn arguments() {
1326 let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned()));
1327 assert!(matches!(args(&[]), Ok(Args::Run(Options { demo: false, .. }))));
1328 assert!(matches!(args(&["--demo"]), Ok(Args::Run(Options { demo: true, .. }))));
1329 assert!(matches!(args(&["--debug"]), Ok(Args::Run(Options { debug: true, .. }))));
1330 assert!(matches!(
1331 args(&["--theme", "x.json"]),
1332 Ok(Args::Run(Options { theme: Some(_), .. }))
1333 ));
1334 assert!(matches!(
1335 args(&["--device", "88:0E:85:46:FE:5C"]),
1336 Ok(Args::Run(Options { device: Some(_), .. }))
1337 ));
1338 assert!(matches!(args(&["--help"]), Ok(Args::Help)));
1339 assert!(args(&["--theme"]).is_err());
1340 assert!(args(&["--device", "nope"]).is_err());
1341 assert!(args(&["--bogus"]).is_err());
1342 }
1343
1344 /// Renders every page to `target/gui-snapshots/`, so the look can be checked without a
1345 /// display. `BUDCTL_THEME=<file>` renders with that palette.
1346 #[test]
1347 fn pages_render() {
1348 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
1349 let _ = fs::remove_dir_all(&dir);
1350 let font = theme::system_font();
1351 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
1352 let on = |page: Page, keys: &str| {
1353 let mut app = App::new(palette.clone(), font, None, true);
1354 app.page = page;
1355 press(&mut app, keys);
1356 app
1357 };
1358 let mut shots: Vec<(String, f32, App)> = Page::ALL
1359 .into_iter()
1360 .map(|page| (format!("{page:?}").to_lowercase(), 800.0, on(page, "")))
1361 .collect();
1362 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
1363 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
1364 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
1365 shots.push(("equalizer-cursor".to_owned(), 1000.0, on(Page::Equalizer, "G G k k")));
1366 let mut connecting = App::new(palette.clone(), font, None, false);
1367 connecting.link = Link::Connecting;
1368 shots.push(("connecting".to_owned(), 800.0, connecting));
1369 let mut busy = App::new(palette.clone(), font, None, false);
1370 busy.answer(Event::Failed {
1371 kind: Kind::Busy,
1372 message: "control channel busy: is another app connected?".to_owned(),
1373 });
1374 shots.push(("busy".to_owned(), 800.0, busy));
1375 let (mut saving, _commands) = live();
1376 saving.answer(Event::Read(Box::new(reading())));
1377 saving.answer(Event::Failed {
1378 kind: Kind::Failed,
1379 message: "the buds did not answer: no reply to command 0x0403".to_owned(),
1380 });
1381 let _ = saving.update(Message::Switch("spatial", false));
1382 let _ = saving.update(Message::Noise(NoiseMode::Off));
1383 shots.push(("saving".to_owned(), 1240.0, saving));
1384 for (name, height, app) in shots {
1385 let settings = iced::Settings {
1386 default_font: font,
1387 ..iced::Settings::default()
1388 };
1389 let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
1390 let snapshot = ui.snapshot(&app.theme).unwrap();
1391 assert!(snapshot.matches_image(dir.join(name)).unwrap());
1392 }
1393 }
1394}