Krolyxon
Commit 904ab9a

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

1379 lines · 49.6 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.
497 fn select(&mut self, id: u8) {
498 let Some(preset) = self.buds.preset(id) else {
499 return;
500 };
501 let command = if preset.custom {
502 Command::CustomEq(buds::key(preset), Vec::new())
503 } else {
504 Command::Eq(preset.name.clone())
505 };
506 self.write(Item::Preset(id), command);
507 }
508
509 /// Moves a band on the screen, within the preset's range. Nothing goes to the buds yet.
510 fn move_band(&mut self, id: u8, hz: u16, gain: i8) {
511 let Some(preset) = self.buds.preset(id) else {
512 return;
513 };
514 let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX));
515 let mut bands = match self.draft.take() {
516 Some(draft) if draft.preset == id => draft.bands,
517 _ => preset.bands.clone(),
518 };
519 if let Some(band) = bands.iter_mut().find(|band| band.hz == hz) {
520 band.gain = gain.clamp(low, high);
521 }
522 self.draft = Some(Draft { preset: id, bands });
523 }
524
525 /// Saves the moved bands of a custom preset. If a write to it is on its way, they go out
526 /// when it is answered: the latest bands win.
527 fn save_bands(&mut self, id: u8) {
528 let Some(draft) = self.draft.clone().filter(|draft| draft.preset == id) else {
529 return;
530 };
531 let Some(preset) = self.buds.preset(id) else {
532 return;
533 };
534 if !self.pending().contains(&Item::Preset(id)) {
535 self.write(Item::Preset(id), Command::CustomEq(buds::key(preset), draft.bands));
536 }
537 }
538
539 /// The items with a write on its way.
540 fn pending(&self) -> Vec<Item> {
541 self.in_flight.iter().flatten().copied().collect()
542 }
543
544 /// Shows `page` from its top, with the cursor on its first item.
545 fn open(&mut self, page: Page) -> Task<Message> {
546 self.page = page;
547 self.cursor = 0;
548 operation::snap_to(SCROLL, RelativeOffset::START)
549 }
550
551 fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> {
552 let Some(action) = self.keys.press(key, modifiers) else {
553 return Task::none();
554 };
555 if self.help {
556 // The list of keys is in front of everything: a key only closes it.
557 self.help = false;
558 return Task::none();
559 }
560 match action {
561 Action::Quit => iced::exit(),
562 Action::Help => {
563 self.help = true;
564 Task::none()
565 }
566 Action::Escape => {
567 self.cursor_shown = false;
568 Task::none()
569 }
570 Action::Refresh => {
571 self.refresh();
572 Task::none()
573 }
574 Action::NextPage => self.open(self.page.step(true)),
575 Action::PreviousPage => self.open(self.page.step(false)),
576 Action::Page(n) => match Page::ALL.get(n) {
577 Some(&page) => self.open(page),
578 None => Task::none(),
579 },
580 Action::Down
581 | Action::Up
582 | Action::First
583 | Action::Last
584 | Action::Left
585 | Action::Right
586 | Action::Activate => self.on_cursor(action),
587 }
588 }
589
590 /// What the current page offers the keyboard cursor, top to bottom.
591 fn items(&self) -> Vec<Item> {
592 match self.page {
593 Page::Overview if self.has_reading() => overview::items(&self.buds),
594 Page::Equalizer if self.has_reading() => equalizer::items(&self.buds),
595 _ => Vec::new(),
596 }
597 }
598
599 /// Whether there is anything to show: the made-up buds, or a reading of the real ones.
600 fn has_reading(&self) -> bool {
601 self.link == Link::Demo || self.read_at.is_some()
602 }
603
604 /// The item under the cursor, when the cursor shows.
605 fn focus(&self) -> Option<Item> {
606 let items = self.items();
607 let last = items.len().checked_sub(1)?;
608 self.cursor_shown.then(|| items.get(self.cursor.min(last)).copied())?
609 }
610
611 fn on_cursor(&mut self, action: Action) -> Task<Message> {
612 let items = self.items();
613 let Some(last) = items.len().checked_sub(1) else {
614 return Task::none();
615 };
616 self.cursor = self.cursor.min(last);
617 if !self.cursor_shown {
618 // The first key only shows the cursor, so nothing changes where it cannot be seen.
619 self.cursor_shown = true;
620 return self.follow();
621 }
622 match action {
623 Action::Down => self.cursor = (self.cursor + 1).min(last),
624 Action::Up => self.cursor = self.cursor.saturating_sub(1),
625 Action::First => self.cursor = 0,
626 Action::Last => self.cursor = last,
627 _ => {
628 let change = items.get(self.cursor).and_then(|&item| self.change(item, action));
629 return match change {
630 Some(message) => self.update(message),
631 None => Task::none(),
632 };
633 }
634 }
635 self.follow()
636 }
637
638 /// What `action` (`h`, `l` or Enter) does to `item`, if anything.
639 fn change(&self, item: Item, action: Action) -> Option<Message> {
640 match item {
641 Item::NoiseMode => {
642 let modes = [NoiseMode::Off, NoiseMode::Anc(self.anc_level), NoiseMode::Transparency];
643 let now = self.buds.status.noise_control.as_ref()?;
644 let at = modes.iter().position(|mode| mode.name() == now.mode);
645 step(&modes, at, action).map(Message::Noise)
646 }
647 Item::AncLevel => {
648 let levels = [AncLevel::Light, AncLevel::Medium, AncLevel::Deep];
649 let at = levels.iter().position(|&level| level == self.anc_level);
650 step(&levels, at, action).map(|level| Message::Noise(NoiseMode::Anc(level)))
651 }
652 Item::Switch(key) => {
653 let on = self.buds.status.switches.iter().find(|s| s.key == key)?.on;
654 let want = match action {
655 Action::Left => false,
656 Action::Right => true,
657 _ => !on,
658 };
659 (want != on).then_some(Message::Switch(key, want))
660 }
661 Item::Open(page) => (action != Action::Left).then_some(Message::Open(page)),
662 Item::Preset(id) => {
663 let chosen = self.buds.status.eq.as_deref() == self.buds.preset(id).map(buds::key).as_deref();
664 (action != Action::Left && !chosen).then_some(Message::Preset(id))
665 }
666 Item::Band(id, hz) => {
667 let preset = self.buds.preset(id)?;
668 let gain = match &self.draft {
669 Some(draft) if draft.preset == id => &draft.bands,
670 _ => &preset.bands,
671 }
672 .iter()
673 .find(|band| band.hz == hz)?
674 .gain;
675 let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX));
676 let to = match action {
677 Action::Left => gain.checked_sub(1)?.max(low),
678 Action::Right => gain.checked_add(1)?.min(high),
679 _ => return None,
680 };
681 (to != gain).then_some(Message::Band {
682 preset: id,
683 hz,
684 gain: to,
685 save: true,
686 })
687 }
688 }
689 }
690
691 /// Scrolls the page so the cursor stays in view: the first item at the top, the last at the
692 /// bottom, the others in between.
693 fn follow(&self) -> Task<Message> {
694 let count = self.items().len();
695 if !self.cursor_shown || count < 2 {
696 return Task::none();
697 }
698 let y = self.cursor as f32 / (count - 1) as f32;
699 operation::snap_to(SCROLL, RelativeOffset { x: 0.0, y })
700 }
701
702 fn refresh_palette(&mut self) {
703 let palette = match self.palette.refresh() {
704 None => return,
705 Some(Ok(palette)) => palette,
706 Some(Err(e)) => {
707 eprintln!("budctl-gui: {e}; using the built-in colors");
708 theme::BUILT_IN
709 }
710 };
711 self.look.p = palette;
712 self.theme = theme::iced_theme(&palette);
713 }
714
715 fn theme(&self) -> Theme {
716 self.theme.clone()
717 }
718
719 fn subscription(&self) -> Subscription<Message> {
720 let events = event::listen_with(|event, _status, _window| match event {
721 iced::Event::Window(window::Event::Focused) => Some(Message::Focused),
722 iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
723 Some(Message::Key(key, modifiers))
724 }
725 iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer),
726 _ => None,
727 });
728 if self.link == Link::Demo {
729 return events;
730 }
731 let worker = Subscription::run_with(self.device, worker::run).map(Message::Worker);
732 Subscription::batch([events, worker])
733 }
734
735 fn view(&self) -> Element<'_, Message> {
736 let look = self.look;
737 let pending = self.pending();
738 let page = match self.page {
739 Page::Overview if self.has_reading() => {
740 let marks = Marks {
741 focus: self.focus(),
742 pending: &pending,
743 };
744 overview::view(look, &self.buds, self.anc_level, marks)
745 }
746 Page::Overview => self.connecting(),
747 Page::Equalizer if self.has_reading() => {
748 let marks = Marks {
749 focus: self.focus(),
750 pending: &pending,
751 };
752 equalizer::view(look, &self.buds, self.draft.as_ref(), marks)
753 }
754 page => column![
755 ui::title(look, page.title()),
756 text("This page comes in a later step.").size(14).color(look.p.dim)
757 ]
758 .spacing(12)
759 .into(),
760 };
761 let page = column![self.problem(), page].spacing(18);
762 let p = look.p;
763 let content = scrollable(
764 container(container(page).max_width(860))
765 .center_x(Length::Fill)
766 .padding([30, 36]),
767 )
768 .id(SCROLL)
769 .height(Length::Fill)
770 .style(move |theme, status| {
771 let mut style = scrollable::default(theme, status);
772 for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
773 rail.background = None;
774 rail.border = Border::default();
775 rail.scroller.background = p.surface_highest.into();
776 rail.scroller.border = Border::default().rounded(4);
777 }
778 style
779 });
780 let window = row![self.sidebar(), ui::divider(look), content];
781 if self.help {
782 stack![window, self.help()].into()
783 } else {
784 window.into()
785 }
786 }
787
788 /// The Overview before the first reading: connecting, or why it did not work.
789 fn connecting(&self) -> Element<'_, Message> {
790 let look = self.look;
791 let p = look.p;
792 let (headline, detail) = match &self.link {
793 Link::Failed {
794 kind: Kind::NotFound,
795 message,
796 } => ("Your buds are not connected", message.as_str()),
797 Link::Failed {
798 kind: Kind::Busy,
799 message,
800 } => ("The control channel is busy", message.as_str()),
801 Link::Failed { message, .. } => ("Something went wrong", message.as_str()),
802 _ => (
803 "Connecting to your buds…",
804 "budctl looks for the paired buds it supports and reads them.",
805 ),
806 };
807 let retry = matches!(self.link, Link::Failed { .. })
808 .then(|| container(ui::outline_button(look, "Try again", Message::Refresh)).width(180));
809 let words = column![
810 text(headline).size(20).font(look.bold()).color(p.fg),
811 text(detail).size(14).color(p.dim)
812 ]
813 .spacing(8);
814 column![
815 ui::title(look, "Overview"),
816 ui::panel(look, column![words].push(retry).spacing(18).into())
817 ]
818 .spacing(18)
819 .into()
820 }
821
822 /// A failure after the buds were read once: what went wrong, above the page.
823 fn problem(&self) -> Option<Element<'_, Message>> {
824 let Link::Failed { message, .. } = &self.link else {
825 return None;
826 };
827 // Before the first reading, the Overview says it already.
828 self.read_at?;
829 let look = self.look;
830 let p = look.p;
831 let content = row![
832 text(message.as_str()).size(14).color(p.err).width(Length::Fill),
833 container(ui::outline_button(look, "Try again", Message::Refresh)).width(140)
834 ]
835 .spacing(16)
836 .align_y(Alignment::Center);
837 Some(
838 container(content)
839 .padding([12, 16])
840 .width(Length::Fill)
841 .style(move |_| container::Style {
842 background: Some(p.surface.into()),
843 border: Border {
844 color: p.err,
845 width: 1.0,
846 radius: 14.0.into(),
847 },
848 ..container::Style::default()
849 })
850 .into(),
851 )
852 }
853
854 fn sidebar(&self) -> Element<'_, Message> {
855 let look = self.look;
856 let p = look.p;
857 let (light, state) = match &self.link {
858 Link::Demo | Link::Live => (p.ok, "Connected"),
859 Link::Connecting => (p.dim, "Connecting…"),
860 Link::Failed { kind: Kind::Busy, .. } => (p.err, "Busy"),
861 Link::Failed { .. } if !self.buds.connected => (p.dim, "Not connected"),
862 Link::Failed { .. } => (p.err, "Error"),
863 };
864 let device = container(
865 column![
866 text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg),
867 row![ui::light(light), text(state).size(13).color(p.dim), space::horizontal()]
868 .push((self.link == Link::Demo).then(|| ui::tag(look, "demo")))
869 .spacing(8)
870 .align_y(Alignment::Center),
871 ui::outline_button(look, "Refresh", Message::Refresh),
872 ]
873 .spacing(12),
874 )
875 .padding(16)
876 .style(move |_| container::Style {
877 background: Some(p.surface.into()),
878 border: Border {
879 color: p.line,
880 width: 1.0,
881 radius: 18.0.into(),
882 },
883 ..container::Style::default()
884 });
885 let pages = Page::ALL.into_iter().fold(Column::new().spacing(3), |pages, page| {
886 pages.push(ui::nav_item(
887 look,
888 page.icon(),
889 page.title(),
890 page == self.page,
891 Message::Open(page),
892 ))
893 });
894 let hint = text("Press ? for keys").size(12.5).color(p.dim);
895 container(column![device, pages, space::vertical(), hint].spacing(22))
896 .padding([18, 14])
897 .width(268)
898 .height(Length::Fill)
899 .style(move |_| container::Style {
900 background: Some(p.surface_low.into()),
901 ..container::Style::default()
902 })
903 .into()
904 }
905
906 /// The list of keys, over a dimmed window. A key or a click closes it.
907 fn help(&self) -> Element<'_, Message> {
908 let look = self.look;
909 let p = look.p;
910 let rows = keys::HELP.iter().fold(Column::new().spacing(9), |rows, &(keys, what)| {
911 rows.push(row![
912 text(keys).size(14).font(look.medium()).color(p.fg).width(170),
913 text(what).size(14).color(p.dim)
914 ])
915 });
916 let list = ui::panel(look, column![ui::title(look, "Keys"), rows].spacing(18).into());
917 let backdrop = container(container(list).width(560))
918 .center(Length::Fill)
919 .style(|_| container::Style {
920 background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
921 ..container::Style::default()
922 });
923 opaque(mouse_area(backdrop).on_press(Message::CloseHelp))
924 }
925}
926
927/// The choice `action` moves to from the one at `at`: `h` and `l` stop at the ends, Enter goes
928/// round. With nothing chosen, any of them picks the first.
929fn step<T: Copy>(choices: &[T], at: Option<usize>, action: Action) -> Option<T> {
930 let to = match (action, at) {
931 (_, None) => 0,
932 (Action::Left, Some(at)) => at.checked_sub(1)?,
933 (Action::Right, Some(at)) => at + 1,
934 (Action::Activate, Some(at)) => (at + 1).checked_rem(choices.len())?,
935 _ => return None,
936 };
937 choices.get(to).copied()
938}
939
940/// The level a [`NoiseControl::level`] names.
941fn anc_level(name: &str) -> Option<AncLevel> {
942 [AncLevel::Light, AncLevel::Medium, AncLevel::Deep]
943 .into_iter()
944 .find(|level| level.name() == name)
945}
946
947/// The preset a write to `item` is for.
948fn item_preset(item: Item) -> u8 {
949 match item {
950 Item::Preset(id) | Item::Band(id, _) => id,
951 _ => 0,
952 }
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958 use iced::keyboard::key::Named;
959 use std::fs;
960 use std::path::Path;
961 use std::sync::mpsc::{self, Receiver};
962
963 /// With the made-up buds.
964 fn app() -> App {
965 App::new(None, Font::DEFAULT, None, true)
966 }
967
968 /// Talking to a worker that the test plays: the receiver gets what the app sends it.
969 fn live() -> (App, Receiver<Command>) {
970 let mut app = App::new(None, Font::DEFAULT, None, false);
971 let (worker, commands) = mpsc::channel();
972 let _ = app.update(Message::Worker(Event::Ready(worker)));
973 (app, commands)
974 }
975
976 fn reading() -> Reading {
977 Reading {
978 status: buds::demo().status,
979 eq: buds::demo().eq,
980 }
981 }
982
983 /// Presses keys, given by name and separated by spaces: `j`, `G`, `Enter`, `Esc`.
984 fn press(app: &mut App, keys: &str) {
985 for name in keys.split_whitespace() {
986 let (key, modifiers) = match name {
987 "Enter" => (Key::Named(Named::Enter), Modifiers::empty()),
988 "Esc" => (Key::Named(Named::Escape), Modifiers::empty()),
989 c if c.chars().all(char::is_uppercase) => (Key::Character(c.into()), Modifiers::SHIFT),
990 c => (Key::Character(c.into()), Modifiers::empty()),
991 };
992 let _ = app.update(Message::Key(key, modifiers));
993 }
994 }
995
996 fn mode(app: &App) -> &str {
997 &app.buds.status.noise_control.as_ref().unwrap().mode
998 }
999
1000 fn switch(app: &App, key: &str) -> bool {
1001 app.buds.status.switches.iter().find(|s| s.key == key).unwrap().on
1002 }
1003
1004 #[test]
1005 fn noise_cancelling_remembers_its_level() {
1006 let mut app = app();
1007 let _ = app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
1008 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1009 assert_eq!(app.anc_level, AncLevel::Light);
1010 let now = app.buds.status.noise_control.as_ref().unwrap();
1011 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None));
1012 let _ = app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
1013 let now = app.buds.status.noise_control.as_ref().unwrap();
1014 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
1015 }
1016
1017 #[test]
1018 fn switches_change_by_key() {
1019 let mut app = app();
1020 let _ = app.update(Message::Switch("game", true));
1021 assert!(switch(&app, "game"));
1022 }
1023
1024 #[test]
1025 fn the_overview_items_in_order() {
1026 let mut app = app();
1027 assert_eq!(
1028 app.items(),
1029 [
1030 Item::NoiseMode,
1031 Item::AncLevel,
1032 Item::Switch("game"),
1033 Item::Switch("spatial"),
1034 Item::Switch("bass-boost"),
1035 Item::Switch("wind"),
1036 Item::Switch("dual"),
1037 Item::Open(Page::Equalizer),
1038 Item::Open(Page::Gestures),
1039 Item::Open(Page::Earbuds),
1040 ]
1041 );
1042 let _ = app.update(Message::Noise(NoiseMode::Off));
1043 assert!(!app.items().contains(&Item::AncLevel));
1044 }
1045
1046 #[test]
1047 fn the_first_key_only_shows_the_cursor() {
1048 let mut app = app();
1049 assert_eq!(app.focus(), None);
1050 press(&mut app, "l");
1051 assert_eq!(app.focus(), Some(Item::NoiseMode));
1052 assert_eq!(mode(&app), "anc");
1053 }
1054
1055 #[test]
1056 fn h_and_l_step_through_choices_and_enter_goes_round() {
1057 let mut app = app();
1058 press(&mut app, "j l");
1059 assert_eq!(mode(&app), "transparency");
1060 press(&mut app, "l");
1061 assert_eq!(mode(&app), "transparency");
1062 press(&mut app, "h h h");
1063 assert_eq!(mode(&app), "off");
1064 press(&mut app, "Enter Enter");
1065 assert_eq!(mode(&app), "transparency");
1066 press(&mut app, "Enter");
1067 assert_eq!(mode(&app), "off");
1068 // On the strength: deep at first.
1069 press(&mut app, "l j");
1070 assert_eq!(app.focus(), Some(Item::AncLevel));
1071 press(&mut app, "h h h");
1072 assert_eq!(app.anc_level, AncLevel::Light);
1073 press(&mut app, "Enter");
1074 assert_eq!(app.anc_level, AncLevel::Medium);
1075 assert_eq!(
1076 app.buds.status.noise_control.as_ref().unwrap().level.as_deref(),
1077 Some("medium")
1078 );
1079 }
1080
1081 #[test]
1082 fn switches_take_h_l_and_enter() {
1083 let mut app = app();
1084 press(&mut app, "j j j");
1085 assert_eq!(app.focus(), Some(Item::Switch("game")));
1086 press(&mut app, "l");
1087 assert!(switch(&app, "game"));
1088 press(&mut app, "l");
1089 assert!(switch(&app, "game"));
1090 press(&mut app, "h");
1091 assert!(!switch(&app, "game"));
1092 press(&mut app, "Enter");
1093 assert!(switch(&app, "game"));
1094 }
1095
1096 #[test]
1097 fn moving_stops_at_the_ends() {
1098 let mut app = app();
1099 press(&mut app, "k k");
1100 assert_eq!(app.focus(), Some(Item::NoiseMode));
1101 press(&mut app, "G j j");
1102 assert_eq!(app.focus(), Some(Item::Open(Page::Earbuds)));
1103 press(&mut app, "g g");
1104 assert_eq!(app.focus(), Some(Item::NoiseMode));
1105 }
1106
1107 #[test]
1108 fn rows_open_pages_and_the_cursor_starts_over() {
1109 let mut app = app();
1110 press(&mut app, "G G h");
1111 assert_eq!(app.page, Page::Overview);
1112 press(&mut app, "l");
1113 assert_eq!(app.page, Page::Earbuds);
1114 assert_eq!(app.cursor, 0);
1115 assert!(app.cursor_shown);
1116 }
1117
1118 #[test]
1119 fn pages_by_key() {
1120 let mut app = app();
1121 press(&mut app, "J");
1122 assert_eq!(app.page, Page::Equalizer);
1123 press(&mut app, "K K");
1124 assert_eq!(app.page, Page::Settings);
1125 press(&mut app, "1");
1126 assert_eq!(app.page, Page::Overview);
1127 press(&mut app, "g t");
1128 assert_eq!(app.page, Page::Equalizer);
1129 press(&mut app, "g T");
1130 assert_eq!(app.page, Page::Overview);
1131 press(&mut app, "9");
1132 assert_eq!(app.page, Page::Overview);
1133 }
1134
1135 #[test]
1136 fn esc_and_clicks_hide_the_cursor() {
1137 let mut app = app();
1138 press(&mut app, "j j");
1139 assert!(app.focus().is_some());
1140 press(&mut app, "Esc");
1141 assert_eq!(app.focus(), None);
1142 press(&mut app, "j");
1143 let _ = app.update(Message::Pointer);
1144 assert_eq!(app.focus(), None);
1145 // It comes back where it was.
1146 press(&mut app, "j");
1147 assert_eq!(app.focus(), Some(Item::AncLevel));
1148 }
1149
1150 #[test]
1151 fn the_list_of_keys_closes_on_any_key() {
1152 let mut app = app();
1153 press(&mut app, "?");
1154 assert!(app.help);
1155 press(&mut app, "j");
1156 assert!(!app.help);
1157 assert_eq!(app.focus(), None);
1158 press(&mut app, "?");
1159 let _ = app.update(Message::CloseHelp);
1160 assert!(!app.help);
1161 }
1162
1163 #[test]
1164 fn live_buds_are_read_first() {
1165 let (mut app, commands) = live();
1166 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1167 assert_eq!(app.link, Link::Connecting);
1168 assert!(app.items().is_empty(), "nothing to move over before the first reading");
1169 // A second refresh while the read is on its way sends nothing.
1170 press(&mut app, "r");
1171 assert!(commands.try_recv().is_err());
1172
1173 let _ = app.update(Message::Worker(Event::Found("realme Buds T310".to_owned())));
1174 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1175 assert_eq!(app.link, Link::Live);
1176 assert_eq!(app.buds.name, "realme Buds T310");
1177 assert!(app.buds.connected);
1178 assert_eq!(app.items().len(), 10);
1179 }
1180
1181 #[test]
1182 fn live_writes_wait_for_the_buds() {
1183 let (mut app, commands) = live();
1184 let _ = commands.try_recv();
1185 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1186
1187 let _ = app.update(Message::Switch("game", true));
1188 assert!(matches!(commands.try_recv(), Ok(Command::Switch("game", true))));
1189 assert!(!switch(&app, "game"), "the switch changes when the buds say so");
1190 assert_eq!(app.pending(), [Item::Switch("game")]);
1191 // While it is on its way, the same switch takes no other write.
1192 press(&mut app, "j j j Enter");
1193 assert!(commands.try_recv().is_err());
1194
1195 let game = Switch {
1196 key: "game",
1197 label: "Game mode",
1198 on: true,
1199 };
1200 let _ = app.update(Message::Worker(Event::Switch(game)));
1201 assert!(switch(&app, "game"));
1202 assert!(app.pending().is_empty());
1203
1204 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1205 assert!(matches!(
1206 commands.try_recv(),
1207 Ok(Command::Noise(NoiseMode::Transparency))
1208 ));
1209 assert_eq!(mode(&app), "anc");
1210 let now = NoiseControl {
1211 mode: "transparency".to_owned(),
1212 level: None,
1213 };
1214 let _ = app.update(Message::Worker(Event::Noise(now)));
1215 assert_eq!(mode(&app), "transparency");
1216 }
1217
1218 #[test]
1219 fn failures_say_why_and_clear_the_write() {
1220 let (mut app, commands) = live();
1221 let _ = app.update(Message::Worker(Event::Failed {
1222 kind: Kind::Busy,
1223 message: "control channel busy".to_owned(),
1224 }));
1225 assert!(matches!(app.link, Link::Failed { kind: Kind::Busy, .. }));
1226 assert!(app.problem().is_none(), "the Overview says it, not a banner");
1227
1228 let _ = commands.try_recv();
1229 let _ = app.update(Message::Refresh);
1230 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1231 assert_eq!(app.link, Link::Connecting);
1232 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1233
1234 let _ = app.update(Message::Switch("dual", false));
1235 let _ = app.update(Message::Worker(Event::Failed {
1236 kind: Kind::NotFound,
1237 message: "the buds closed the control link".to_owned(),
1238 }));
1239 assert!(app.pending().is_empty());
1240 assert!(switch(&app, "dual"));
1241 assert!(!app.buds.connected);
1242 assert!(app.problem().is_some());
1243 }
1244
1245 #[test]
1246 fn presets_and_bands_by_key() {
1247 let mut app = app();
1248 press(&mut app, "J");
1249 assert_eq!(app.page, Page::Equalizer);
1250 press(&mut app, "j l");
1251 assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
1252 assert_eq!(app.buds.eq_name(), "Pure raw");
1253 press(&mut app, "G");
1254 assert_eq!(app.focus(), Some(Item::Band(4, 16000)));
1255 // Moving a band saves the custom preset, which also selects it.
1256 press(&mut app, "l");
1257 assert_eq!(app.buds.status.eq.as_deref(), Some("custom-4"));
1258 let last = |app: &App| app.buds.preset(4).unwrap().bands.last().unwrap().gain;
1259 assert_eq!(last(&app), 3);
1260 press(&mut app, "l l l l l");
1261 assert_eq!(last(&app), 6, "it stops at the top of the range");
1262 }
1263
1264 #[test]
1265 fn band_moves_wait_for_the_write_before_them() {
1266 let (mut app, commands) = live();
1267 let _ = commands.try_recv();
1268 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1269 press(&mut app, "J G G l");
1270 let gain = |bands: &[EqBand]| bands.last().map(|band| band.gain);
1271 assert!(matches!(
1272 commands.try_recv(),
1273 Ok(Command::CustomEq(name, bands)) if name == "custom-4" && gain(&bands) == Some(3)
1274 ));
1275 // While that write is on its way, moves only change the screen.
1276 press(&mut app, "l l");
1277 assert!(commands.try_recv().is_err());
1278 assert_eq!(app.draft.as_ref().and_then(|draft| gain(&draft.bands)), Some(5));
1279 // The answer comes: the latest bands go out at once.
1280 let mut saved = buds::demo().preset(4).unwrap().clone();
1281 if let Some(band) = saved.bands.last_mut() {
1282 band.gain = 3;
1283 }
1284 let _ = app.update(Message::Worker(Event::CustomEq(saved.clone())));
1285 assert!(matches!(
1286 commands.try_recv(),
1287 Ok(Command::CustomEq(_, bands)) if gain(&bands) == Some(5)
1288 ));
1289 assert_eq!(app.pending(), [Item::Preset(4)]);
1290 // A failure drops the moves: the screen shows what the buds kept.
1291 let _ = app.update(Message::Worker(Event::Failed {
1292 kind: Kind::Failed,
1293 message: "no reply".to_owned(),
1294 }));
1295 assert_eq!(app.draft, None);
1296 assert_eq!(gain(&app.buds.preset(4).unwrap().bands), Some(3));
1297 }
1298
1299 #[test]
1300 fn steps() {
1301 let abc = ['a', 'b', 'c'];
1302 assert_eq!(step(&abc, Some(0), Action::Left), None);
1303 assert_eq!(step(&abc, Some(0), Action::Right), Some('b'));
1304 assert_eq!(step(&abc, Some(2), Action::Right), None);
1305 assert_eq!(step(&abc, Some(2), Action::Activate), Some('a'));
1306 assert_eq!(step(&abc, None, Action::Left), Some('a'));
1307 }
1308
1309 #[test]
1310 fn arguments() {
1311 let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned()));
1312 assert!(matches!(args(&[]), Ok(Args::Run(Options { demo: false, .. }))));
1313 assert!(matches!(args(&["--demo"]), Ok(Args::Run(Options { demo: true, .. }))));
1314 assert!(matches!(args(&["--debug"]), Ok(Args::Run(Options { debug: true, .. }))));
1315 assert!(matches!(
1316 args(&["--theme", "x.json"]),
1317 Ok(Args::Run(Options { theme: Some(_), .. }))
1318 ));
1319 assert!(matches!(
1320 args(&["--device", "88:0E:85:46:FE:5C"]),
1321 Ok(Args::Run(Options { device: Some(_), .. }))
1322 ));
1323 assert!(matches!(args(&["--help"]), Ok(Args::Help)));
1324 assert!(args(&["--theme"]).is_err());
1325 assert!(args(&["--device", "nope"]).is_err());
1326 assert!(args(&["--bogus"]).is_err());
1327 }
1328
1329 /// Renders every page to `target/gui-snapshots/`, so the look can be checked without a
1330 /// display. `BUDCTL_THEME=<file>` renders with that palette.
1331 #[test]
1332 fn pages_render() {
1333 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
1334 let _ = fs::remove_dir_all(&dir);
1335 let font = theme::system_font();
1336 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
1337 let on = |page: Page, keys: &str| {
1338 let mut app = App::new(palette.clone(), font, None, true);
1339 app.page = page;
1340 press(&mut app, keys);
1341 app
1342 };
1343 let mut shots: Vec<(String, f32, App)> = Page::ALL
1344 .into_iter()
1345 .map(|page| (format!("{page:?}").to_lowercase(), 800.0, on(page, "")))
1346 .collect();
1347 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
1348 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
1349 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
1350 shots.push(("equalizer-cursor".to_owned(), 1000.0, on(Page::Equalizer, "G G k k")));
1351 let mut connecting = App::new(palette.clone(), font, None, false);
1352 connecting.link = Link::Connecting;
1353 shots.push(("connecting".to_owned(), 800.0, connecting));
1354 let mut busy = App::new(palette.clone(), font, None, false);
1355 busy.answer(Event::Failed {
1356 kind: Kind::Busy,
1357 message: "control channel busy: is another app connected?".to_owned(),
1358 });
1359 shots.push(("busy".to_owned(), 800.0, busy));
1360 let (mut saving, _commands) = live();
1361 saving.answer(Event::Read(Box::new(reading())));
1362 saving.answer(Event::Failed {
1363 kind: Kind::Failed,
1364 message: "the buds did not answer: no reply to command 0x0403".to_owned(),
1365 });
1366 let _ = saving.update(Message::Switch("spatial", false));
1367 let _ = saving.update(Message::Noise(NoiseMode::Off));
1368 shots.push(("saving".to_owned(), 1240.0, saving));
1369 for (name, height, app) in shots {
1370 let settings = iced::Settings {
1371 default_font: font,
1372 ..iced::Settings::default()
1373 };
1374 let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
1375 let snapshot = ui.snapshot(&app.theme).unwrap();
1376 assert!(snapshot.matches_image(dir.join(name)).unwrap());
1377 }
1378 }
1379}