Krolyxon
Commit 7c15b41

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

2429 lines · 92.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: `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 dots;
9mod earbuds;
10mod equalizer;
11mod gestures;
12mod icons;
13mod keys;
14mod log;
15mod overview;
16mod settings;
17mod theme;
18mod ui;
19mod worker;
20
21use std::collections::VecDeque;
22use std::path::PathBuf;
23use std::process::ExitCode;
24use std::sync::mpsc::Sender;
25use std::time::{Duration, Instant};
26
27use budctl_core::Kind;
28use budctl_core::bt::BdAddr;
29use budctl_core::status::{AncLevel, EqBand, EqPreset, NoiseControl, NoiseMode, Switch};
30use iced::keyboard::{self, Key, Modifiers};
31use iced::widget::operation::{self, RelativeOffset};
32use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text, text_input};
33use iced::{Alignment, Border, Element, Length, Size, Subscription, Task, Theme, event, mouse, window};
34
35use keys::Action;
36use theme::Look;
37use worker::{Command, Event, Reading};
38
39const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>] [--demo] [--debug]
40
41--device picks the buds when more than one supported pair is connected.
42--demo shows made-up buds, without Bluetooth.
43--debug hex-dumps every frame sent and received on stderr, as budctl --debug does.
44Colors come from --theme, else from ~/.config/budctl/theme.json, else built in. That file is
45written with the built-in colors when missing. It takes the Material 3 keys of a matugen
46palette like Aurora's (primary, tertiary, surfaceLow, outline, …).
47Preferences (default device, idle time, start page): ~/.config/budctl/config.json.
48Each launch logs errors and session events to ~/.local/state/budctl/gui.log.
49Press ? in the window for the keys.";
50
51/// The page's scrolling area, so the cursor can be kept in view.
52const SCROLL: &str = "page";
53/// The name field of the preset-name dialog, so it can take the keyboard.
54const NAME_INPUT: &str = "name";
55
56/// When the window gets focus and the last reading is older than this, the buds are read again:
57/// the phone may have changed something meanwhile.
58const STALE: Duration = Duration::from_secs(30);
59
60fn main() -> ExitCode {
61 let options = match parse_args(std::env::args().skip(1)) {
62 Ok(Args::Run(options)) => options,
63 Ok(Args::Help) => {
64 println!("{USAGE}");
65 return ExitCode::SUCCESS;
66 }
67 Err(message) => {
68 eprintln!("budctl-gui: {message}\n{USAGE}");
69 return ExitCode::from(1);
70 }
71 };
72 if options.debug {
73 budctl_core::debug::enable();
74 }
75 if let Some(file) = log::file()
76 && let Err(e) = log::start(&file)
77 {
78 eprintln!("budctl-gui: cannot write the log {}: {e}", file.display());
79 }
80 let config = load_config();
81 let start_page = Page::named(&config.gui.start_page).unwrap_or_else(|| {
82 warn(format!(
83 "gui.start_page: no page {:?}; opening Overview",
84 config.gui.start_page
85 ));
86 Page::Overview
87 });
88 let idle = Duration::from_secs(config.gui.idle_seconds);
89 let device = options.device.or_else(|| config.device().unwrap_or_default());
90 if options.theme.is_none() {
91 write_built_in_theme();
92 }
93 let theme_file = options.theme.clone().or_else(theme::default_file);
94 let boot = move || {
95 let mut app = App::new(theme_file.clone(), device, options.demo);
96 app.page = start_page;
97 app.idle = idle;
98 app
99 };
100 let mut application = iced::application(boot, App::update, App::view);
101 for font in theme::FONTS {
102 application = application.font(font);
103 }
104 let result = application
105 .title("budctl")
106 .theme(App::theme)
107 .subscription(App::subscription)
108 .default_font(theme::UI)
109 .window(window::Settings {
110 size: Size::new(1040.0, 800.0),
111 min_size: Some(Size::new(760.0, 540.0)),
112 platform_specific: window::settings::PlatformSpecific {
113 application_id: "budctl".to_owned(),
114 ..window::settings::PlatformSpecific::default()
115 },
116 ..window::Settings::default()
117 })
118 .run();
119 match result {
120 Ok(()) => ExitCode::SUCCESS,
121 Err(e) => {
122 eprintln!("budctl-gui: {e}");
123 ExitCode::FAILURE
124 }
125 }
126}
127
128/// A problem worth knowing that does not stop the window: on stderr and in the log.
129fn warn(message: String) {
130 eprintln!("budctl-gui: {message}");
131 log::line(message);
132}
133
134/// The config, with the defaults when it cannot be used.
135fn load_config() -> budctl_core::config::Config {
136 let (config, problem) = budctl_core::config::load();
137 match problem {
138 Some(problem) => warn(format!("{problem}; using the defaults")),
139 None => log::line(format!("config: {config:?}")),
140 }
141 config
142}
143
144/// Write the built-in colors to `~/.config/budctl/theme.json` when there is no such file, as a
145/// start for a matugen template.
146fn write_built_in_theme() {
147 let Some(file) = theme::default_file() else {
148 return;
149 };
150 match budctl_core::config::create(&file, &theme::to_json(&theme::BUILT_IN)) {
151 Ok(()) => log::line(format!("wrote the built-in colors to {}", file.display())),
152 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
153 Err(e) => warn(format!("cannot write {}: {e}", file.display())),
154 }
155}
156
157#[derive(Default)]
158struct Options {
159 theme: Option<PathBuf>,
160 device: Option<BdAddr>,
161 demo: bool,
162 debug: bool,
163}
164
165enum Args {
166 Run(Options),
167 Help,
168}
169
170fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
171 let mut options = Options::default();
172 while let Some(arg) = args.next() {
173 match arg.as_str() {
174 "--theme" => options.theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)),
175 "--device" => {
176 let address = args.next().ok_or("--device needs an address")?;
177 options.device = Some(address.parse().map_err(|e| format!("--device: {e}"))?);
178 }
179 "--demo" => options.demo = true,
180 "--debug" => options.debug = true,
181 "-h" | "--help" => return Ok(Args::Help),
182 other => return Err(format!("unknown argument {other:?}")),
183 }
184 }
185 Ok(Args::Run(options))
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189enum Page {
190 Overview,
191 Equalizer,
192 Gestures,
193 Earbuds,
194 Settings,
195}
196
197impl Page {
198 const ALL: [Page; 5] = [
199 Page::Overview,
200 Page::Equalizer,
201 Page::Gestures,
202 Page::Earbuds,
203 Page::Settings,
204 ];
205
206 fn title(self) -> &'static str {
207 match self {
208 Page::Overview => "Overview",
209 Page::Equalizer => "Equalizer",
210 Page::Gestures => "Gestures",
211 Page::Earbuds => "Earbud settings",
212 Page::Settings => "App settings",
213 }
214 }
215
216 /// The page's word in the sidebar, short enough to stand large in dots.
217 fn word(self) -> &'static str {
218 match self {
219 Page::Overview => "overview",
220 Page::Equalizer => "eq",
221 Page::Gestures => "gestures",
222 Page::Earbuds => "buds",
223 Page::Settings => "app",
224 }
225 }
226
227 /// A page by its name in the config: `overview`, `equalizer`, `gestures`, `earbuds`, `settings`.
228 fn named(name: &str) -> Option<Page> {
229 match name {
230 "overview" => Some(Page::Overview),
231 "equalizer" => Some(Page::Equalizer),
232 "gestures" => Some(Page::Gestures),
233 "earbuds" => Some(Page::Earbuds),
234 "settings" => Some(Page::Settings),
235 _ => None,
236 }
237 }
238
239 /// The next page in the sidebar, or the previous one, going round.
240 fn step(self, forward: bool) -> Page {
241 let count = Page::ALL.len();
242 let at = Page::ALL.iter().position(|&page| page == self).unwrap_or(0);
243 let to = if forward { at + 1 } else { at + count - 1 };
244 Page::ALL.get(to % count).copied().unwrap_or(self)
245 }
246}
247
248/// What the keyboard cursor can rest on, and what a write changes.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250enum Item {
251 NoiseMode,
252 AncLevel,
253 /// A switch, by its key.
254 Switch(&'static str),
255 /// A row that opens a page.
256 Open(Page),
257 /// An EQ preset, by its id.
258 Preset(u8),
259 /// One band of a custom EQ preset: the preset's id and the band's frequency.
260 Band(u8, u16),
261 /// A bud's gesture, by its place among the Gestures page's rows.
262 Gesture(u8),
263 /// A mode of the hold cycle, by its place among the modes the buds offer.
264 Cycle(u8),
265 /// Find my earbuds.
266 Find,
267 /// The bass level.
268 Bass,
269 /// "New preset", on the Equalizer page.
270 NewPreset,
271 /// "Rename", on a custom EQ preset's card.
272 Rename(u8),
273 /// The bin on a custom EQ preset's row.
274 Delete(u8),
275 /// The pencil on a custom EQ preset's row: open its bands.
276 Edit(u8),
277 /// "Done", on the open bands.
278 Done,
279}
280
281/// The dialog that asks for a preset's name.
282#[derive(Debug, Clone, PartialEq)]
283struct NamePrompt {
284 target: NameTarget,
285 text: String,
286 /// What is wrong with the name, after a try.
287 problem: Option<String>,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq)]
291enum NameTarget {
292 New,
293 /// A custom preset, by its id.
294 Rename(u8),
295}
296
297/// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the
298/// items with a write on its way.
299#[derive(Clone, Copy)]
300struct Marks<'a> {
301 focus: Option<Item>,
302 pending: &'a [Item],
303}
304
305/// A gesture's action picked on the screen and not on the buds yet.
306#[derive(Debug, Clone, PartialEq)]
307struct TouchDraft {
308 /// The gesture, by its place among the Gestures page's rows.
309 row: u8,
310 action: String,
311}
312
313/// Bands of a custom EQ preset moved on the screen and not saved on the buds yet.
314#[derive(Debug, Clone, PartialEq)]
315struct Draft {
316 preset: u8,
317 /// All the preset's bands, as shown.
318 bands: Vec<EqBand>,
319}
320
321/// Where the connection to the buds stands.
322#[derive(Debug, Clone, PartialEq)]
323enum Link {
324 /// Made-up buds (`--demo`): nothing talks to Bluetooth.
325 Demo,
326 /// Looking for the buds, or reading them for the first time.
327 Connecting,
328 /// The buds answered the last command.
329 Live,
330 /// The last command failed.
331 Failed { kind: Kind, message: String },
332}
333
334#[derive(Debug, Clone)]
335enum Message {
336 Open(Page),
337 Noise(NoiseMode),
338 /// A switch's key, and whether to turn it on.
339 Switch(&'static str, bool),
340 /// Select an EQ preset, by its id.
341 Preset(u8),
342 /// Move a band of a custom EQ preset. With `save`, save the preset right away, else when
343 /// [`Message::SaveBands`] comes (the mouse lets go of the slider).
344 Band {
345 preset: u8,
346 hz: u16,
347 gain: i8,
348 save: bool,
349 },
350 /// Save the moved bands of a custom EQ preset.
351 SaveBands(u8),
352 /// Give a gesture (by its row) an action. With `save`, write it right away.
353 Action {
354 row: u8,
355 action: String,
356 save: bool,
357 },
358 /// Put a mode (by its place) in the hold cycle, or take it out.
359 Cycle(u8, bool),
360 /// Ring the buds (after a confirmation), or stop them ringing.
361 Find,
362 /// The answer to "Ring the buds?".
363 ConfirmFind(bool),
364 /// Move the bass level. With `save`, set it on the buds right away, else when
365 /// [`Message::SaveBass`] comes (the mouse lets go of the slider).
366 Bass {
367 level: i8,
368 save: bool,
369 },
370 SaveBass,
371 /// Ask for a new preset's name.
372 NewPreset,
373 /// Ask for a custom preset's (by its id) new name.
374 RenamePreset(u8),
375 NameInput(String),
376 SubmitName,
377 CancelName,
378 /// Open a custom preset's (by its id) bands, or close them if they are open.
379 EditPreset(u8),
380 /// Close the open bands.
381 DoneEditing,
382 /// Delete a custom preset (by its id), after a confirmation.
383 DeletePreset(u8),
384 /// The answer to "Delete the preset?".
385 ConfirmDelete(bool),
386 /// The answer to "Turn dual connection off?".
387 ConfirmDual(bool),
388 ShowHelp,
389 /// Read the buds again.
390 Refresh,
391 Worker(Event),
392 /// The window got focus: the palette file may have changed meanwhile, and so may the buds.
393 Focused,
394 Key(Key, Modifiers),
395 /// A mouse button went down: the mouse is in use, so the keyboard cursor hides.
396 Pointer,
397 CloseHelp,
398}
399
400struct App {
401 page: Page,
402 buds: buds::Buds,
403 /// The level "Noise cancelling" turns on at: the one last reported or picked.
404 anc_level: AncLevel,
405 look: Look,
406 theme: Theme,
407 palette: theme::Source,
408 keys: keys::Keys,
409 /// Where the keyboard cursor is, among [`App::items`].
410 cursor: usize,
411 /// Whether it shows. Only keys show it; a click hides it.
412 cursor_shown: bool,
413 /// Whether the list of keys is open.
414 help: bool,
415 /// `--device`, else the config's, for the worker.
416 device: Option<BdAddr>,
417 /// How long the worker keeps an idle session open.
418 idle: Duration,
419 link: Link,
420 /// Where commands go, once the worker is up.
421 worker: Option<Sender<Command>>,
422 /// The commands sent and not answered yet, oldest first: the item each one writes, or `None`
423 /// for a read. The worker answers them in order.
424 in_flight: VecDeque<Option<Item>>,
425 /// When the buds were last read.
426 read_at: Option<Instant>,
427 /// Bands moved and not saved yet.
428 draft: Option<Draft>,
429 /// A gesture's action picked and not written yet.
430 touch_draft: Option<TouchDraft>,
431 /// A bass level moved and not set yet.
432 bass_draft: Option<i8>,
433 /// The buds were told to ring and not yet to stop.
434 ringing: bool,
435 /// "Ring the buds?" is open.
436 confirm_find: bool,
437 /// Why the palette file could not be used, if it could not.
438 palette_problem: Option<String>,
439 /// The preset-name dialog, when it is open.
440 prompt: Option<NamePrompt>,
441 /// "Delete the preset?" is open, for this preset.
442 confirm_delete: Option<u8>,
443 /// The custom preset whose bands are open on the Equalizer page.
444 editing: Option<u8>,
445 /// "Turn dual connection off?" is open.
446 confirm_dual: bool,
447}
448
449impl App {
450 fn new(theme_file: Option<PathBuf>, device: Option<BdAddr>, demo: bool) -> Self {
451 let buds = if demo { buds::demo() } else { buds::unknown() };
452 let anc_level = buds
453 .status
454 .noise_control
455 .as_ref()
456 .and_then(|now| now.level.as_deref())
457 .and_then(anc_level)
458 .unwrap_or(AncLevel::Deep);
459 let mut app = App {
460 page: Page::Overview,
461 buds,
462 anc_level,
463 look: Look { p: theme::BUILT_IN },
464 theme: theme::iced_theme(&theme::BUILT_IN),
465 palette: theme::Source::new(theme_file),
466 keys: keys::Keys::default(),
467 cursor: 0,
468 cursor_shown: false,
469 help: false,
470 device,
471 idle: worker::IDLE,
472 link: if demo { Link::Demo } else { Link::Connecting },
473 worker: None,
474 in_flight: VecDeque::new(),
475 read_at: None,
476 draft: None,
477 touch_draft: None,
478 bass_draft: None,
479 ringing: false,
480 confirm_find: false,
481 palette_problem: None,
482 prompt: None,
483 confirm_delete: None,
484 editing: None,
485 confirm_dual: false,
486 };
487 app.refresh_palette();
488 app
489 }
490
491 fn update(&mut self, message: Message) -> Task<Message> {
492 match message {
493 Message::Open(page) => return self.open(page),
494 Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)),
495 // Turning dual connection off may drop one of two connected devices: ask first.
496 Message::Switch(earbuds::DUAL, false) => self.confirm_dual = true,
497 Message::Switch(key, on) => self.write(Item::Switch(key), Command::Switch(key, on)),
498 Message::ConfirmDual(yes) => {
499 self.confirm_dual = false;
500 if yes {
501 self.write(Item::Switch(earbuds::DUAL), Command::Switch(earbuds::DUAL, false));
502 }
503 }
504 Message::Preset(id) => self.select(id),
505 Message::Band { preset, hz, gain, save } => {
506 self.move_band(preset, hz, gain);
507 if save {
508 self.save_bands(preset);
509 }
510 }
511 Message::SaveBands(preset) => self.save_bands(preset),
512 Message::Action { row, action, save } => {
513 self.touch_draft = Some(TouchDraft { row, action });
514 if save {
515 self.save_touch(row);
516 }
517 }
518 Message::Cycle(mode, on) => self.cycle(mode, on),
519 Message::Find if self.ringing => self.write(Item::Find, Command::Find(false)),
520 Message::Find => self.confirm_find = true,
521 Message::ConfirmFind(ring) => {
522 self.confirm_find = false;
523 if ring {
524 self.write(Item::Find, Command::Find(true));
525 }
526 }
527 Message::ShowHelp => self.help = true,
528 Message::Bass { level, save } => {
529 let (low, high) = self.buds.bass_range.unwrap_or((i8::MIN, i8::MAX));
530 self.bass_draft = Some(level.clamp(low, high));
531 if save {
532 self.save_bass();
533 }
534 }
535 Message::SaveBass => self.save_bass(),
536 Message::NewPreset => return self.ask_name(NameTarget::New, String::new()),
537 Message::RenamePreset(id) => {
538 let name = self.buds.preset(id).map(|p| p.name.clone()).unwrap_or_default();
539 return self.ask_name(NameTarget::Rename(id), name);
540 }
541 Message::NameInput(text) => {
542 if let Some(prompt) = &mut self.prompt {
543 prompt.text = text;
544 prompt.problem = None;
545 }
546 }
547 Message::SubmitName => self.submit_name(),
548 Message::CancelName => self.prompt = None,
549 Message::EditPreset(id) => {
550 self.editing = if self.editing == Some(id) { None } else { Some(id) };
551 }
552 Message::DoneEditing => self.editing = None,
553 Message::DeletePreset(id) => self.confirm_delete = Some(id),
554 Message::ConfirmDelete(yes) => {
555 if let (true, Some(id)) = (yes, self.confirm_delete.take())
556 && let Some(preset) = self.buds.preset(id)
557 {
558 let command = Command::DeleteEq(buds::key(preset));
559 if self.editing == Some(id) {
560 self.editing = None;
561 }
562 self.write(Item::Preset(id), command);
563 }
564 self.confirm_delete = None;
565 }
566 Message::Refresh => self.refresh(),
567 Message::Worker(event) => self.answer(event),
568 Message::Focused => {
569 self.refresh_palette();
570 // The phone may have changed something, or the buds may be back: read again
571 // when the reading is old or the last command failed.
572 let stale = self.read_at.is_some_and(|at| at.elapsed() >= STALE);
573 let failed = matches!(self.link, Link::Failed { .. });
574 if (stale || failed) && self.in_flight.is_empty() {
575 self.refresh();
576 }
577 }
578 Message::Key(key, modifiers) => return self.key(&key, modifiers),
579 Message::Pointer => self.cursor_shown = false,
580 Message::CloseHelp => self.help = false,
581 }
582 Task::none()
583 }
584
585 /// Changes a setting: at once with the made-up buds, else through the worker. Until the buds
586 /// answer, the item shows that it is saving and takes no other write.
587 fn write(&mut self, item: Item, command: Command) {
588 if self.link == Link::Demo {
589 match command {
590 Command::Noise(mode) => self.apply_noise(NoiseControl {
591 mode: mode.name().to_owned(),
592 level: match mode {
593 NoiseMode::Anc(level) => Some(level.name().to_owned()),
594 NoiseMode::Off | NoiseMode::Transparency => None,
595 },
596 }),
597 Command::Switch(key, on) => {
598 if let Some(switch) = self.buds.status.switches.iter().find(|s| s.key == key) {
599 let switch = Switch { on, ..switch.clone() };
600 self.apply_switch(switch);
601 }
602 }
603 Command::Eq(preset) => self.buds.set_eq(preset),
604 Command::Touch { side, gesture, action } => {
605 if let Some(entry) = self.buds.touch.as_mut().and_then(|touch| {
606 touch
607 .gestures
608 .iter_mut()
609 .find(|g| g.side == side && g.gesture == gesture && g.group.is_none())
610 }) {
611 entry.action = action;
612 }
613 self.touch_draft = None;
614 }
615 Command::HoldCycle(modes) => {
616 if let Some(touch) = &mut self.buds.touch {
617 touch.hold_cycle = Some(modes);
618 }
619 }
620 Command::Find(ring) => self.ringing = ring,
621 Command::Bass(level) => {
622 self.buds.bass = Some(level);
623 self.bass_draft = None;
624 }
625 Command::CreateEq(name) => {
626 let customs = || {
627 self.buds
628 .eq
629 .iter()
630 .flat_map(|eq| eq.presets.iter())
631 .filter(|p| p.custom)
632 };
633 // As the buds do: the bands of another preset, else the T310's own, all flat.
634 let (bands, range) = customs().next().map_or_else(
635 || (vec![50, 250, 1000, 4000, 8000, 16000], Some((-6, 6))),
636 |model| (model.bands.iter().map(|b| b.hz).collect(), model.range),
637 );
638 let id = customs().map(|p| p.id).max().map_or(4, |id| id.saturating_add(1));
639 self.editing = Some(id);
640 self.buds.set_custom(EqPreset {
641 name,
642 id,
643 custom: true,
644 bands: bands.into_iter().map(|hz| EqBand { hz, gain: 0 }).collect(),
645 range,
646 });
647 }
648 Command::DeleteEq(_) => {
649 let id = item_preset(item);
650 if let Some(eq) = &mut self.buds.eq {
651 eq.presets.retain(|p| !(p.custom && p.id == id));
652 }
653 if self.buds.status.eq.as_deref() == Some(&format!("custom-{id}")) {
654 self.buds.set_eq("pure-raw".to_owned());
655 }
656 self.draft = None;
657 }
658 Command::RenameEq(_, name) => {
659 if let Some(preset) = self.buds.preset(item_preset(item)) {
660 let preset = EqPreset { name, ..preset.clone() };
661 self.buds.set_custom(preset);
662 }
663 }
664 Command::CustomEq(_, bands) => {
665 if let Some(preset) = self.buds.preset(item_preset(item)) {
666 let mut preset = preset.clone();
667 for band in &bands {
668 if let Some(shown) = preset.bands.iter_mut().find(|b| b.hz == band.hz) {
669 shown.gain = band.gain;
670 }
671 }
672 self.buds.set_custom(preset);
673 }
674 self.draft = None;
675 }
676 Command::Read => {}
677 }
678 } else if !self.pending().contains(&item) {
679 self.send(command, Some(item));
680 }
681 }
682
683 /// Reads the buds again; with the made-up buds, starts them over.
684 fn refresh(&mut self) {
685 match self.link {
686 Link::Demo => {
687 self.buds = buds::demo();
688 self.draft = None;
689 self.touch_draft = None;
690 }
691 _ if self.in_flight.contains(&None) => {}
692 _ => {
693 if self.read_at.is_none() {
694 self.link = Link::Connecting;
695 }
696 self.send(Command::Read, None);
697 }
698 }
699 }
700
701 fn send(&mut self, command: Command, item: Option<Item>) {
702 let Some(worker) = &self.worker else {
703 return;
704 };
705 if worker.send(command).is_ok() {
706 self.in_flight.push_back(item);
707 }
708 }
709
710 /// Takes in what the worker reports.
711 fn answer(&mut self, event: Event) {
712 match event {
713 Event::Ready(worker) => {
714 self.worker = Some(worker);
715 self.send(Command::Read, None);
716 }
717 Event::Found(name) => self.buds.name = name,
718 Event::Lost(message) => {
719 self.buds.connected = false;
720 self.link = Link::Failed {
721 kind: Kind::NotFound,
722 message,
723 };
724 }
725 Event::Read(reading) => {
726 self.in_flight.pop_front();
727 self.apply_reading(*reading);
728 }
729 Event::Noise(now) => {
730 self.in_flight.pop_front();
731 self.apply_noise(now);
732 self.link = Link::Live;
733 }
734 Event::Switch(switch) => {
735 self.in_flight.pop_front();
736 self.apply_switch(switch);
737 self.link = Link::Live;
738 }
739 Event::Eq(now) => {
740 self.in_flight.pop_front();
741 self.buds.set_eq(now);
742 self.link = Link::Live;
743 }
744 Event::CustomEq(preset) => {
745 self.in_flight.pop_front();
746 let id = preset.id;
747 self.buds.set_custom(preset);
748 self.link = Link::Live;
749 // Bands moved while this write was on its way go out now.
750 match &self.draft {
751 Some(draft) if draft.preset == id => {
752 if self.buds.preset(id).is_some_and(|saved| saved.bands == draft.bands) {
753 self.draft = None;
754 } else {
755 self.save_bands(id);
756 }
757 }
758 _ => {}
759 }
760 }
761 Event::Touch(gestures) => {
762 let done = self.in_flight.pop_front();
763 if let Some(touch) = &mut self.buds.touch {
764 touch.gestures = gestures;
765 }
766 self.link = Link::Live;
767 // An action picked while this write was on its way goes out now.
768 if let Some(Some(Item::Gesture(row))) = done {
769 self.save_touch(row);
770 }
771 }
772 Event::Presets(eq) => {
773 let done = self.in_flight.pop_front();
774 self.buds.set_presets(eq);
775 self.link = Link::Live;
776 // A new preset opens its bands: it is the one in use.
777 if done == Some(Some(Item::NewPreset)) {
778 self.editing = equalizer::in_use(&self.buds).map(|preset| preset.id);
779 }
780 }
781 Event::Bass(level) => {
782 self.in_flight.pop_front();
783 self.buds.bass = Some(level);
784 self.link = Link::Live;
785 // A level moved while this one was on its way goes out now.
786 self.save_bass();
787 }
788 Event::Ringing(ring) => {
789 self.in_flight.pop_front();
790 self.ringing = ring;
791 self.link = Link::Live;
792 }
793 Event::HoldCycle(modes) => {
794 self.in_flight.pop_front();
795 if let Some(touch) = &mut self.buds.touch {
796 touch.hold_cycle = Some(modes);
797 }
798 self.link = Link::Live;
799 }
800 Event::Failed { kind, message } => {
801 // The buds keep what they had: show that, not what was picked.
802 match self.in_flight.pop_front() {
803 Some(Some(Item::Preset(id))) if self.draft.as_ref().is_some_and(|d| d.preset == id) => {
804 self.draft = None;
805 }
806 Some(Some(Item::Gesture(row))) if self.touch_draft.as_ref().is_some_and(|d| d.row == row) => {
807 self.touch_draft = None;
808 }
809 Some(Some(Item::Bass)) => self.bass_draft = None,
810 _ => {}
811 }
812 if kind == Kind::NotFound {
813 self.buds.connected = false;
814 }
815 self.link = Link::Failed { kind, message };
816 }
817 }
818 }
819
820 fn apply_reading(&mut self, reading: Reading) {
821 self.buds.eq = reading.eq;
822 self.buds.touch = reading.touch;
823 self.buds.bass_range = reading.bass_range;
824 self.buds.dual = reading.dual;
825 if let Some(level) = reading
826 .status
827 .noise_control
828 .as_ref()
829 .and_then(|now| now.level.as_deref())
830 .and_then(anc_level)
831 {
832 self.anc_level = level;
833 }
834 self.buds.status = reading.status;
835 self.buds.connected = true;
836 self.link = Link::Live;
837 self.read_at = Some(Instant::now());
838 }
839
840 fn apply_noise(&mut self, now: NoiseControl) {
841 if let Some(level) = now.level.as_deref().and_then(anc_level) {
842 self.anc_level = level;
843 }
844 self.buds.status.noise_control = Some(now);
845 }
846
847 fn apply_switch(&mut self, switch: Switch) {
848 if let Some(shown) = self.buds.status.switches.iter_mut().find(|s| s.key == switch.key) {
849 *shown = switch;
850 }
851 }
852
853 /// Selects an EQ preset: a built-in one by name, a custom one by saving it unchanged. The
854 /// preset in use is left alone.
855 fn select(&mut self, id: u8) {
856 let Some(preset) = self.buds.preset(id) else {
857 return;
858 };
859 if self.buds.status.eq.as_deref() == Some(buds::key(preset).as_str()) {
860 return;
861 }
862 let command = if preset.custom {
863 Command::CustomEq(buds::key(preset), Vec::new())
864 } else {
865 Command::Eq(preset.name.clone())
866 };
867 self.write(Item::Preset(id), command);
868 }
869
870 /// Moves a band on the screen, within the preset's range. Nothing goes to the buds yet.
871 fn move_band(&mut self, id: u8, hz: u16, gain: i8) {
872 let Some(preset) = self.buds.preset(id) else {
873 return;
874 };
875 let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX));
876 let mut bands = match self.draft.take() {
877 Some(draft) if draft.preset == id => draft.bands,
878 _ => preset.bands.clone(),
879 };
880 if let Some(band) = bands.iter_mut().find(|band| band.hz == hz) {
881 band.gain = gain.clamp(low, high);
882 }
883 self.draft = Some(Draft { preset: id, bands });
884 }
885
886 /// Saves the moved bands of a custom preset. If a write to it is on its way, they go out
887 /// when it is answered: the latest bands win.
888 fn save_bands(&mut self, id: u8) {
889 let Some(draft) = self.draft.clone().filter(|draft| draft.preset == id) else {
890 return;
891 };
892 let Some(preset) = self.buds.preset(id) else {
893 return;
894 };
895 if !self.pending().contains(&Item::Preset(id)) {
896 self.write(Item::Preset(id), Command::CustomEq(buds::key(preset), draft.bands));
897 }
898 }
899
900 /// A gesture row: the bud, the gesture, and the actions it takes.
901 fn gesture_row(&self, row: u8) -> Option<(&'static str, String, Vec<String>)> {
902 let touch = self.buds.touch.as_ref()?;
903 let rows = gestures::rows(touch);
904 let found = rows.get(usize::from(row))?;
905 Some((found.side, found.gesture.to_owned(), found.actions.to_vec()))
906 }
907
908 /// The action a gesture row has: the one picked on the screen, else the buds' own.
909 fn gesture_action(&self, row: u8) -> Option<String> {
910 if let Some(draft) = self.touch_draft.as_ref().filter(|draft| draft.row == row) {
911 return Some(draft.action.clone());
912 }
913 let (side, gesture, _) = self.gesture_row(row)?;
914 self.buds.action(side, &gesture).map(str::to_owned)
915 }
916
917 /// Writes the action picked for a gesture row. If a write to it is on its way, the pick
918 /// goes out when that is answered: the latest pick wins.
919 fn save_touch(&mut self, row: u8) {
920 let Some(draft) = self.touch_draft.clone().filter(|draft| draft.row == row) else {
921 return;
922 };
923 let Some((side, gesture, _)) = self.gesture_row(row) else {
924 return;
925 };
926 if self.buds.action(side, &gesture) == Some(draft.action.as_str()) {
927 self.touch_draft = None;
928 } else if !self.pending().contains(&Item::Gesture(row)) {
929 let command = Command::Touch {
930 side: side.to_owned(),
931 gesture,
932 action: draft.action,
933 };
934 self.write(Item::Gesture(row), command);
935 }
936 }
937
938 /// Puts a mode in the hold cycle or takes it out. The buds need two modes to step
939 /// through, so the last two stay.
940 fn cycle(&mut self, mode: u8, on: bool) {
941 let Some(touch) = &self.buds.touch else {
942 return;
943 };
944 let (Some(name), Some(now)) = (touch.cycle_modes.get(usize::from(mode)), &touch.hold_cycle) else {
945 return;
946 };
947 let mut modes: Vec<String> = now.iter().filter(|m| *m != name).cloned().collect();
948 if on {
949 modes.push(name.clone());
950 }
951 let busy = self.pending().iter().any(|item| matches!(item, Item::Cycle(_)));
952 if modes.len() >= 2 && !busy {
953 self.write(Item::Cycle(mode), Command::HoldCycle(modes));
954 }
955 }
956
957 /// Opens the preset-name dialog, with the keyboard in its field.
958 fn ask_name(&mut self, target: NameTarget, text: String) -> Task<Message> {
959 self.prompt = Some(NamePrompt {
960 target,
961 text,
962 problem: None,
963 });
964 operation::focus(NAME_INPUT)
965 }
966
967 /// Creates or renames the preset, if the name will do. What the buds themselves refuse (a
968 /// name too long for them, a fourth preset) comes back as an error.
969 fn submit_name(&mut self) {
970 let Some(prompt) = self.prompt.clone() else {
971 return;
972 };
973 let renaming = match prompt.target {
974 NameTarget::Rename(id) => Some(id),
975 NameTarget::New => None,
976 };
977 let taken = self
978 .buds
979 .custom_names()
980 .any(|(id, name)| name == prompt.text && Some(id) != renaming);
981 let problem = if prompt.text.is_empty() || prompt.text.trim() != prompt.text {
982 Some("A name cannot be empty, or start or end with a space.".to_owned())
983 } else if prompt.text.chars().any(char::is_control) {
984 Some("A name cannot hold control characters.".to_owned())
985 } else if taken {
986 Some(format!("A preset is called {} already.", prompt.text))
987 } else {
988 None
989 };
990 if let Some(problem) = problem {
991 self.prompt = Some(NamePrompt {
992 problem: Some(problem),
993 ..prompt
994 });
995 return;
996 }
997 self.prompt = None;
998 match prompt.target {
999 NameTarget::New => self.write(Item::NewPreset, Command::CreateEq(prompt.text)),
1000 NameTarget::Rename(id) => {
1001 let unchanged = self.buds.preset(id).is_some_and(|p| p.name == prompt.text);
1002 if let (false, Some(preset)) = (unchanged, self.buds.preset(id)) {
1003 let command = Command::RenameEq(buds::key(preset), prompt.text);
1004 self.write(Item::Preset(id), command);
1005 }
1006 }
1007 }
1008 }
1009
1010 /// Sets the bass level moved on the screen. If a level is on its way, this one goes out
1011 /// when that is answered: the latest level wins.
1012 fn save_bass(&mut self) {
1013 let Some(level) = self.bass_draft else {
1014 return;
1015 };
1016 if self.buds.bass == Some(level) {
1017 self.bass_draft = None;
1018 } else if !self.pending().contains(&Item::Bass) {
1019 self.write(Item::Bass, Command::Bass(level));
1020 }
1021 }
1022
1023 /// The items with a write on its way.
1024 fn pending(&self) -> Vec<Item> {
1025 self.in_flight.iter().flatten().copied().collect()
1026 }
1027
1028 /// Shows `page` from its top, with the cursor on its first item.
1029 fn open(&mut self, page: Page) -> Task<Message> {
1030 self.page = page;
1031 self.cursor = 0;
1032 operation::snap_to(SCROLL, RelativeOffset::START)
1033 }
1034
1035 fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> {
1036 if self.confirm_dual {
1037 // Only Enter turns it off; any other key says no.
1038 let yes = matches!(key, Key::Named(keyboard::key::Named::Enter));
1039 return self.update(Message::ConfirmDual(yes));
1040 }
1041 if self.confirm_delete.is_some() {
1042 // Only Enter deletes; any other key says no.
1043 let yes = matches!(key, Key::Named(keyboard::key::Named::Enter));
1044 return self.update(Message::ConfirmDelete(yes));
1045 }
1046 if self.prompt.is_some() {
1047 // The name field takes the keys, and Enter in it; only Esc is ours.
1048 if matches!(key, Key::Named(keyboard::key::Named::Escape)) {
1049 self.prompt = None;
1050 }
1051 return Task::none();
1052 }
1053 let Some(action) = self.keys.press(key, modifiers) else {
1054 return Task::none();
1055 };
1056 if self.help {
1057 // The list of keys is in front of everything: a key only closes it.
1058 self.help = false;
1059 return Task::none();
1060 }
1061 if self.confirm_find {
1062 // Only Enter rings the buds; any other key says no.
1063 let ring = matches!(key, Key::Named(keyboard::key::Named::Enter));
1064 return self.update(Message::ConfirmFind(ring));
1065 }
1066 match action {
1067 Action::Quit => iced::exit(),
1068 Action::Help => {
1069 self.help = true;
1070 Task::none()
1071 }
1072 Action::Escape => {
1073 self.cursor_shown = false;
1074 Task::none()
1075 }
1076 Action::Refresh => {
1077 self.refresh();
1078 Task::none()
1079 }
1080 Action::NextPage => self.open(self.page.step(true)),
1081 Action::PreviousPage => self.open(self.page.step(false)),
1082 Action::Page(n) => match Page::ALL.get(n) {
1083 Some(&page) => self.open(page),
1084 None => Task::none(),
1085 },
1086 Action::Down
1087 | Action::Up
1088 | Action::First
1089 | Action::Last
1090 | Action::Left
1091 | Action::Right
1092 | Action::Activate => self.on_cursor(action),
1093 }
1094 }
1095
1096 /// What the current page offers the keyboard cursor, top to bottom.
1097 fn items(&self) -> Vec<Item> {
1098 match self.page {
1099 Page::Overview if self.has_reading() => overview::items(&self.buds),
1100 Page::Equalizer if self.has_reading() => equalizer::items(&self.buds, self.editing),
1101 Page::Gestures if self.has_reading() => gestures::items(&self.buds),
1102 Page::Earbuds if self.has_reading() => earbuds::items(&self.buds),
1103 _ => Vec::new(),
1104 }
1105 }
1106
1107 /// Whether there is anything to show: the made-up buds, or a reading of the real ones.
1108 fn has_reading(&self) -> bool {
1109 self.link == Link::Demo || self.read_at.is_some()
1110 }
1111
1112 /// The item under the cursor, when the cursor shows.
1113 fn focus(&self) -> Option<Item> {
1114 let items = self.items();
1115 let last = items.len().checked_sub(1)?;
1116 self.cursor_shown.then(|| items.get(self.cursor.min(last)).copied())?
1117 }
1118
1119 fn on_cursor(&mut self, action: Action) -> Task<Message> {
1120 let items = self.items();
1121 let Some(last) = items.len().checked_sub(1) else {
1122 return Task::none();
1123 };
1124 self.cursor = self.cursor.min(last);
1125 if !self.cursor_shown {
1126 // The first key only shows the cursor, so nothing changes where it cannot be seen.
1127 self.cursor_shown = true;
1128 return self.follow();
1129 }
1130 match action {
1131 Action::Down => self.cursor = (self.cursor + 1).min(last),
1132 Action::Up => self.cursor = self.cursor.saturating_sub(1),
1133 Action::First => self.cursor = 0,
1134 Action::Last => self.cursor = last,
1135 _ => {
1136 let change = items.get(self.cursor).and_then(|&item| self.change(item, action));
1137 return match change {
1138 Some(message) => self.update(message),
1139 None => Task::none(),
1140 };
1141 }
1142 }
1143 self.follow()
1144 }
1145
1146 /// What `action` (`h`, `l` or Enter) does to `item`, if anything.
1147 fn change(&self, item: Item, action: Action) -> Option<Message> {
1148 match item {
1149 Item::NoiseMode => {
1150 let modes = [NoiseMode::Off, NoiseMode::Anc(self.anc_level), NoiseMode::Transparency];
1151 let now = self.buds.status.noise_control.as_ref()?;
1152 let at = modes.iter().position(|mode| mode.name() == now.mode);
1153 step(&modes, at, action).map(Message::Noise)
1154 }
1155 Item::AncLevel => {
1156 let levels = [AncLevel::Light, AncLevel::Medium, AncLevel::Deep];
1157 let at = levels.iter().position(|&level| level == self.anc_level);
1158 step(&levels, at, action).map(|level| Message::Noise(NoiseMode::Anc(level)))
1159 }
1160 Item::Switch(key) => {
1161 let on = self.buds.status.switches.iter().find(|s| s.key == key)?.on;
1162 let want = match action {
1163 Action::Left => false,
1164 Action::Right => true,
1165 _ => !on,
1166 };
1167 (want != on).then_some(Message::Switch(key, want))
1168 }
1169 Item::Open(page) => (action != Action::Left).then_some(Message::Open(page)),
1170 Item::Preset(id) => {
1171 let chosen = self.buds.status.eq.as_deref() == self.buds.preset(id).map(buds::key).as_deref();
1172 (action != Action::Left && !chosen).then_some(Message::Preset(id))
1173 }
1174 Item::Band(id, hz) => {
1175 let preset = self.buds.preset(id)?;
1176 let gain = match &self.draft {
1177 Some(draft) if draft.preset == id => &draft.bands,
1178 _ => &preset.bands,
1179 }
1180 .iter()
1181 .find(|band| band.hz == hz)?
1182 .gain;
1183 let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX));
1184 let to = match action {
1185 Action::Left => gain.checked_sub(1)?.max(low),
1186 Action::Right => gain.checked_add(1)?.min(high),
1187 _ => return None,
1188 };
1189 (to != gain).then_some(Message::Band {
1190 preset: id,
1191 hz,
1192 gain: to,
1193 save: true,
1194 })
1195 }
1196 Item::Gesture(row) => {
1197 let (_, _, actions) = self.gesture_row(row)?;
1198 let now = self.gesture_action(row)?;
1199 let places: Vec<usize> = (0..actions.len()).collect();
1200 let at = actions.iter().position(|a| *a == now);
1201 let to = step(&places, at, action)?;
1202 actions.get(to).map(|action| Message::Action {
1203 row,
1204 action: action.clone(),
1205 save: true,
1206 })
1207 }
1208 Item::Find => (action != Action::Left).then_some(Message::Find),
1209 Item::NewPreset => (action != Action::Left).then_some(Message::NewPreset),
1210 Item::Rename(id) => (action != Action::Left).then_some(Message::RenamePreset(id)),
1211 Item::Delete(id) => (action != Action::Left).then_some(Message::DeletePreset(id)),
1212 Item::Edit(id) => (action != Action::Left).then_some(Message::EditPreset(id)),
1213 Item::Done => (action != Action::Left).then_some(Message::DoneEditing),
1214 Item::Bass => {
1215 let (low, high) = self.buds.bass_range?;
1216 let now = self.bass_draft.or(self.buds.bass);
1217 let to = match (action, now) {
1218 // Not set yet: the first step starts from the middle.
1219 (Action::Left | Action::Right, None) => 0,
1220 (Action::Left, Some(level)) => level.checked_sub(1)?.max(low),
1221 (Action::Right, Some(level)) => level.checked_add(1)?.min(high),
1222 _ => return None,
1223 };
1224 (Some(to) != now).then_some(Message::Bass { level: to, save: true })
1225 }
1226 Item::Cycle(mode) => {
1227 let touch = self.buds.touch.as_ref()?;
1228 let on = touch
1229 .hold_cycle
1230 .as_ref()?
1231 .contains(touch.cycle_modes.get(usize::from(mode))?);
1232 let want = match action {
1233 Action::Left => false,
1234 Action::Right => true,
1235 _ => !on,
1236 };
1237 (want != on).then_some(Message::Cycle(mode, want))
1238 }
1239 }
1240 }
1241
1242 /// Scrolls the page so the cursor stays in view: the first item at the top, the last at the
1243 /// bottom, the others in between.
1244 fn follow(&self) -> Task<Message> {
1245 let count = self.items().len();
1246 if !self.cursor_shown || count < 2 {
1247 return Task::none();
1248 }
1249 let y = self.cursor as f32 / (count - 1) as f32;
1250 operation::snap_to(SCROLL, RelativeOffset { x: 0.0, y })
1251 }
1252
1253 fn refresh_palette(&mut self) {
1254 let palette = match self.palette.refresh() {
1255 None => return,
1256 Some(Ok(palette)) => {
1257 self.palette_problem = None;
1258 palette
1259 }
1260 Some(Err(e)) => {
1261 warn(format!("{e}; using the built-in colors"));
1262 self.palette_problem = Some(e);
1263 theme::BUILT_IN
1264 }
1265 };
1266 self.look.p = palette;
1267 self.theme = theme::iced_theme(&palette);
1268 }
1269
1270 fn theme(&self) -> Theme {
1271 self.theme.clone()
1272 }
1273
1274 fn subscription(&self) -> Subscription<Message> {
1275 let events = event::listen_with(|event, _status, _window| match event {
1276 iced::Event::Window(window::Event::Focused) => Some(Message::Focused),
1277 iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
1278 Some(Message::Key(key, modifiers))
1279 }
1280 iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer),
1281 _ => None,
1282 });
1283 if self.link == Link::Demo {
1284 return events;
1285 }
1286 let worker = Subscription::run_with((self.device, self.idle), worker::run).map(Message::Worker);
1287 Subscription::batch([events, worker])
1288 }
1289
1290 fn view(&self) -> Element<'_, Message> {
1291 let look = self.look;
1292 let pending = self.pending();
1293 let page = match self.page {
1294 Page::Overview if self.has_reading() => {
1295 let marks = Marks {
1296 focus: self.focus(),
1297 pending: &pending,
1298 };
1299 overview::view(look, &self.buds, self.anc_level, self.bass_draft, marks)
1300 }
1301 Page::Overview => self.connecting(),
1302 Page::Equalizer if self.has_reading() => {
1303 let marks = Marks {
1304 focus: self.focus(),
1305 pending: &pending,
1306 };
1307 equalizer::view(look, &self.buds, self.editing, self.draft.as_ref(), marks)
1308 }
1309 Page::Gestures if self.has_reading() => {
1310 let marks = Marks {
1311 focus: self.focus(),
1312 pending: &pending,
1313 };
1314 gestures::view(look, &self.buds, self.touch_draft.as_ref(), marks)
1315 }
1316 Page::Earbuds if self.has_reading() => {
1317 let marks = Marks {
1318 focus: self.focus(),
1319 pending: &pending,
1320 };
1321 earbuds::view(look, &self.buds, self.ringing, marks)
1322 }
1323 Page::Settings => {
1324 let colors = settings::Colors {
1325 file: self.palette.file(),
1326 problem: self.palette_problem.as_deref(),
1327 };
1328 settings::view(look, colors)
1329 }
1330 page => column![
1331 ui::title(look, page.title()),
1332 container(ui::note(look, "Connect your buds to see this page.")).padding([0, 18])
1333 ]
1334 .spacing(16)
1335 .into(),
1336 };
1337 let page = column![self.problem(), page].spacing(20);
1338 let p = look.p;
1339 let content = scrollable(
1340 container(container(page).max_width(860))
1341 .center_x(Length::Fill)
1342 .padding([34, 36]),
1343 )
1344 .id(SCROLL)
1345 .height(Length::Fill)
1346 .style(move |theme, status| {
1347 let mut style = scrollable::default(theme, status);
1348 for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
1349 rail.background = None;
1350 rail.border = Border::default();
1351 rail.scroller.background = p.s3.into();
1352 rail.scroller.border = Border::default().rounded(4);
1353 }
1354 style
1355 });
1356 let window = container(row![self.sidebar(), content])
1357 .width(Length::Fill)
1358 .height(Length::Fill)
1359 .style(move |_| container::Style {
1360 background: Some(p.bg.into()),
1361 ..container::Style::default()
1362 });
1363 if self.help {
1364 stack![window, self.help()].into()
1365 } else if self.confirm_find {
1366 stack![window, self.confirm_find()].into()
1367 } else if let Some(prompt) = &self.prompt {
1368 stack![window, self.name_prompt(prompt)].into()
1369 } else if let Some(id) = self.confirm_delete {
1370 stack![window, self.confirm_delete(id)].into()
1371 } else if self.confirm_dual {
1372 stack![window, self.confirm_dual()].into()
1373 } else {
1374 window.into()
1375 }
1376 }
1377
1378 /// The Overview before the first reading: connecting, or why it did not work.
1379 fn connecting(&self) -> Element<'_, Message> {
1380 let look = self.look;
1381 let p = look.p;
1382 let (headline, detail) = match &self.link {
1383 Link::Failed {
1384 kind: Kind::NotFound,
1385 message,
1386 } => ("Your buds are not connected", message.as_str()),
1387 Link::Failed {
1388 kind: Kind::Busy,
1389 message,
1390 } => ("The control channel is busy", message.as_str()),
1391 Link::Failed { message, .. } => ("Something went wrong", message.as_str()),
1392 _ => (
1393 "Connecting to your buds…",
1394 "budctl looks for the paired buds it supports and reads them.",
1395 ),
1396 };
1397 let failed = matches!(self.link, Link::Failed { .. });
1398 let retry = failed.then(|| container(ui::wide_button(look, "Try again", Message::Refresh)).width(180));
1399 let state = if failed {
1400 row![ui::dot(p.err, 10.0), ui::label(look, "no link", 11.0, p.err)]
1401 } else {
1402 row![ui::dot(p.live, 10.0), ui::label(look, "searching", 11.0, p.live)]
1403 };
1404 let words = column![
1405 state.spacing(10).align_y(Alignment::Center),
1406 ui::headline(look, headline),
1407 ui::note(look, detail)
1408 ]
1409 .spacing(10);
1410 column![
1411 ui::title(look, "overview · battery, noise control, sound"),
1412 ui::panel(look, column![words].push(retry).spacing(22).into())
1413 ]
1414 .spacing(20)
1415 .into()
1416 }
1417
1418 /// A failure after the buds were read once: what went wrong, above the page.
1419 fn problem(&self) -> Option<Element<'_, Message>> {
1420 let Link::Failed { message, .. } = &self.link else {
1421 return None;
1422 };
1423 // Before the first reading, the Overview says it already.
1424 self.read_at?;
1425 let look = self.look;
1426 let p = look.p;
1427 let content = row![
1428 ui::dot(p.err, 10.0),
1429 container(text(message.as_str()).size(14).color(p.err)).width(Length::Fill),
1430 container(ui::wide_button(look, "Try again", Message::Refresh)).width(150)
1431 ]
1432 .spacing(14)
1433 .align_y(Alignment::Center);
1434 Some(
1435 container(content)
1436 .padding([12, 22])
1437 .width(Length::Fill)
1438 .style(move |_| container::Style {
1439 background: Some(p.s1.into()),
1440 border: Border::default().rounded(999),
1441 ..container::Style::default()
1442 })
1443 .into(),
1444 )
1445 }
1446
1447 fn sidebar(&self) -> Element<'_, Message> {
1448 let look = self.look;
1449 let p = look.p;
1450 let (light, state) = match &self.link {
1451 Link::Demo | Link::Live => (p.fill, "connected"),
1452 Link::Connecting => (p.live, "connecting…"),
1453 Link::Failed { kind: Kind::Busy, .. } => (p.err, "busy"),
1454 Link::Failed { .. } if !self.buds.connected => (p.mute, "not connected"),
1455 Link::Failed { .. } => (p.err, "error"),
1456 };
1457 let device = column![
1458 text(self.buds.name.as_str()).size(18).font(look.semibold()).color(p.fg),
1459 row![
1460 ui::dot(light, 8.0),
1461 ui::label(look, state, 11.0, p.grey),
1462 space::horizontal()
1463 ]
1464 .push((self.link == Link::Demo).then(|| ui::tag(look, "demo")))
1465 .spacing(8)
1466 .align_y(Alignment::Center),
1467 ui::wide_button(look, "Refresh", Message::Refresh),
1468 ]
1469 .spacing(14)
1470 .padding([0, 10]);
1471 let pages = Page::ALL.into_iter().fold(Column::new().spacing(2), |pages, page| {
1472 pages.push(ui::nav_item(look, page.word(), page == self.page, Message::Open(page)))
1473 });
1474 let hint = container(ui::label(look, "? keys · q quit", 10.5, p.grey)).padding([0, 10]);
1475 container(column![device, pages, space::vertical(), hint].spacing(34))
1476 .padding([34, 18])
1477 .width(272)
1478 .height(Length::Fill)
1479 .into()
1480 }
1481
1482 /// A dialog over the dimmed window: heading, words, and Cancel beside the main action.
1483 fn dialog<'a>(
1484 &self,
1485 heading: String,
1486 words: &'a str,
1487 answer: fn(bool) -> Message,
1488 action: &'a str,
1489 ) -> Element<'a, Message> {
1490 let look = self.look;
1491 let buttons = row![
1492 space::horizontal(),
1493 ui::pill(look, "Cancel", false, answer(false)),
1494 ui::pill(look, action, true, answer(true)),
1495 ]
1496 .spacing(10);
1497 let body = column![ui::headline(look, heading), ui::note(look, words)].spacing(10);
1498 let dialog = ui::panel(look, column![body, buttons].spacing(26).into());
1499 opaque(ui::scrim(look, dialog, 480.0))
1500 }
1501
1502 /// "Ring the buds?": the sound is loud enough to hurt in the ears.
1503 fn confirm_find(&self) -> Element<'_, Message> {
1504 self.dialog(
1505 "Ring your buds?".to_owned(),
1506 "Take them out of your ears first: the sound is loud. They ring until you press Stop.",
1507 Message::ConfirmFind,
1508 "Ring",
1509 )
1510 }
1511
1512 /// "Turn dual connection off?": with two devices connected, the buds may drop one.
1513 fn confirm_dual(&self) -> Element<'_, Message> {
1514 self.dialog(
1515 "Turn dual connection off?".to_owned(),
1516 "If two devices are connected to your buds, they may drop one of them.",
1517 Message::ConfirmDual,
1518 "Turn off",
1519 )
1520 }
1521
1522 /// "Delete the preset?": its bands are lost.
1523 fn confirm_delete(&self, id: u8) -> Element<'_, Message> {
1524 let name = self
1525 .buds
1526 .preset(id)
1527 .map_or("this preset", |preset| preset.name.as_str());
1528 self.dialog(
1529 format!("Delete {name}?"),
1530 "Its bands are lost. The buds keep their other presets.",
1531 Message::ConfirmDelete,
1532 "Delete",
1533 )
1534 }
1535
1536 /// The dialog that asks for a preset's name, over a dimmed window.
1537 fn name_prompt<'a>(&'a self, prompt: &'a NamePrompt) -> Element<'a, Message> {
1538 let look = self.look;
1539 let p = look.p;
1540 let (heading, action) = match prompt.target {
1541 NameTarget::New => ("New preset".to_owned(), "Create"),
1542 NameTarget::Rename(id) => {
1543 let name = self.buds.preset(id).map_or("the preset", |preset| preset.name.as_str());
1544 (format!("Rename {name}"), "Rename")
1545 }
1546 };
1547 let field = text_input("Name", &prompt.text)
1548 .id(NAME_INPUT)
1549 .on_input(Message::NameInput)
1550 .on_submit(Message::SubmitName)
1551 .size(16)
1552 .padding([12, 20])
1553 .style(move |_, _| text_input::Style {
1554 background: p.s2.into(),
1555 border: Border {
1556 color: p.fill,
1557 width: 1.0,
1558 radius: 999.0.into(),
1559 },
1560 icon: p.grey,
1561 placeholder: p.grey,
1562 value: p.fg,
1563 selection: p.container,
1564 });
1565 let problem = prompt
1566 .problem
1567 .as_deref()
1568 .map(|problem| container(text(problem).size(13).color(p.err)).padding([0, 20]));
1569 let buttons = row![
1570 space::horizontal(),
1571 ui::pill(look, "Cancel", false, Message::CancelName),
1572 ui::pill(look, action, true, Message::SubmitName),
1573 ]
1574 .spacing(10);
1575 let dialog = column![ui::headline(look, heading), field]
1576 .push(problem)
1577 .push(buttons)
1578 .spacing(18);
1579 opaque(ui::scrim(look, ui::panel(look, dialog.into()), 480.0))
1580 }
1581
1582 /// The list of keys, over a dimmed window. A key or a click closes it.
1583 fn help(&self) -> Element<'_, Message> {
1584 let look = self.look;
1585 let p = look.p;
1586 let rows = keys::HELP
1587 .iter()
1588 .fold(Column::new().spacing(10), |rows, &(keys, what)| {
1589 rows.push(
1590 row![
1591 text(keys).size(13).font(look.mono()).color(p.fg).width(180),
1592 text(what).size(14).color(p.fg_dim)
1593 ]
1594 .align_y(Alignment::Center),
1595 )
1596 });
1597 let heading = row![ui::num(look, "keys", 40.0, p.fg), ui::dot(p.live, 12.0)]
1598 .spacing(12)
1599 .align_y(Alignment::Center);
1600 let list = ui::panel(look, column![heading, rows].spacing(22).into());
1601 opaque(mouse_area(ui::scrim(look, list, 580.0)).on_press(Message::CloseHelp))
1602 }
1603}
1604
1605/// The choice `action` moves to from the one at `at`: `h` and `l` stop at the ends, Enter goes
1606/// round. With nothing chosen, any of them picks the first.
1607fn step<T: Copy>(choices: &[T], at: Option<usize>, action: Action) -> Option<T> {
1608 let to = match (action, at) {
1609 (_, None) => 0,
1610 (Action::Left, Some(at)) => at.checked_sub(1)?,
1611 (Action::Right, Some(at)) => at + 1,
1612 (Action::Activate, Some(at)) => (at + 1).checked_rem(choices.len())?,
1613 _ => return None,
1614 };
1615 choices.get(to).copied()
1616}
1617
1618/// The level a [`NoiseControl::level`] names.
1619fn anc_level(name: &str) -> Option<AncLevel> {
1620 [AncLevel::Light, AncLevel::Medium, AncLevel::Deep]
1621 .into_iter()
1622 .find(|level| level.name() == name)
1623}
1624
1625/// The preset a write to `item` is for.
1626fn item_preset(item: Item) -> u8 {
1627 match item {
1628 Item::Preset(id) | Item::Band(id, _) => id,
1629 _ => 0,
1630 }
1631}
1632
1633#[cfg(test)]
1634mod tests {
1635 use super::*;
1636 use iced::keyboard::key::Named;
1637 use std::fs;
1638 use std::path::Path;
1639 use std::sync::mpsc::{self, Receiver};
1640
1641 /// With the made-up buds.
1642 fn app() -> App {
1643 App::new(None, None, true)
1644 }
1645
1646 /// Talking to a worker that the test plays: the receiver gets what the app sends it.
1647 fn live() -> (App, Receiver<Command>) {
1648 let mut app = App::new(None, None, false);
1649 let (worker, commands) = mpsc::channel();
1650 let _ = app.update(Message::Worker(Event::Ready(worker)));
1651 (app, commands)
1652 }
1653
1654 fn reading() -> Reading {
1655 Reading {
1656 status: buds::demo().status,
1657 eq: buds::demo().eq,
1658 touch: buds::demo().touch,
1659 bass_range: buds::demo().bass_range,
1660 dual: buds::demo().dual,
1661 }
1662 }
1663
1664 /// Presses keys, given by name and separated by spaces: `j`, `G`, `Enter`, `Esc`.
1665 fn press(app: &mut App, keys: &str) {
1666 for name in keys.split_whitespace() {
1667 let (key, modifiers) = match name {
1668 "Enter" => (Key::Named(Named::Enter), Modifiers::empty()),
1669 "Esc" => (Key::Named(Named::Escape), Modifiers::empty()),
1670 c if c.chars().all(char::is_uppercase) => (Key::Character(c.into()), Modifiers::SHIFT),
1671 c => (Key::Character(c.into()), Modifiers::empty()),
1672 };
1673 let _ = app.update(Message::Key(key, modifiers));
1674 }
1675 }
1676
1677 fn mode(app: &App) -> &str {
1678 &app.buds.status.noise_control.as_ref().unwrap().mode
1679 }
1680
1681 fn switch(app: &App, key: &str) -> bool {
1682 app.buds.status.switches.iter().find(|s| s.key == key).unwrap().on
1683 }
1684
1685 #[test]
1686 fn noise_cancelling_remembers_its_level() {
1687 let mut app = app();
1688 let _ = app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
1689 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1690 assert_eq!(app.anc_level, AncLevel::Light);
1691 let now = app.buds.status.noise_control.as_ref().unwrap();
1692 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None));
1693 let _ = app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
1694 let now = app.buds.status.noise_control.as_ref().unwrap();
1695 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
1696 }
1697
1698 #[test]
1699 fn switches_change_by_key() {
1700 let mut app = app();
1701 let _ = app.update(Message::Switch("game", true));
1702 assert!(switch(&app, "game"));
1703 }
1704
1705 #[test]
1706 fn the_overview_items_in_order() {
1707 let mut app = app();
1708 assert_eq!(
1709 app.items(),
1710 [
1711 Item::NoiseMode,
1712 Item::AncLevel,
1713 Item::Switch("game"),
1714 Item::Switch("spatial"),
1715 Item::Switch("bass-boost"),
1716 Item::Bass,
1717 Item::Switch("wind"),
1718 Item::Switch("dual"),
1719 Item::Open(Page::Equalizer),
1720 Item::Open(Page::Gestures),
1721 Item::Open(Page::Earbuds),
1722 ]
1723 );
1724 let _ = app.update(Message::Noise(NoiseMode::Off));
1725 assert!(!app.items().contains(&Item::AncLevel));
1726 }
1727
1728 #[test]
1729 fn the_first_key_only_shows_the_cursor() {
1730 let mut app = app();
1731 assert_eq!(app.focus(), None);
1732 press(&mut app, "l");
1733 assert_eq!(app.focus(), Some(Item::NoiseMode));
1734 assert_eq!(mode(&app), "anc");
1735 }
1736
1737 #[test]
1738 fn h_and_l_step_through_choices_and_enter_goes_round() {
1739 let mut app = app();
1740 press(&mut app, "j l");
1741 assert_eq!(mode(&app), "transparency");
1742 press(&mut app, "l");
1743 assert_eq!(mode(&app), "transparency");
1744 press(&mut app, "h h h");
1745 assert_eq!(mode(&app), "off");
1746 press(&mut app, "Enter Enter");
1747 assert_eq!(mode(&app), "transparency");
1748 press(&mut app, "Enter");
1749 assert_eq!(mode(&app), "off");
1750 // On the strength: deep at first.
1751 press(&mut app, "l j");
1752 assert_eq!(app.focus(), Some(Item::AncLevel));
1753 press(&mut app, "h h h");
1754 assert_eq!(app.anc_level, AncLevel::Light);
1755 press(&mut app, "Enter");
1756 assert_eq!(app.anc_level, AncLevel::Medium);
1757 assert_eq!(
1758 app.buds.status.noise_control.as_ref().unwrap().level.as_deref(),
1759 Some("medium")
1760 );
1761 }
1762
1763 #[test]
1764 fn switches_take_h_l_and_enter() {
1765 let mut app = app();
1766 press(&mut app, "j j j");
1767 assert_eq!(app.focus(), Some(Item::Switch("game")));
1768 press(&mut app, "l");
1769 assert!(switch(&app, "game"));
1770 press(&mut app, "l");
1771 assert!(switch(&app, "game"));
1772 press(&mut app, "h");
1773 assert!(!switch(&app, "game"));
1774 press(&mut app, "Enter");
1775 assert!(switch(&app, "game"));
1776 }
1777
1778 #[test]
1779 fn moving_stops_at_the_ends() {
1780 let mut app = app();
1781 press(&mut app, "k k");
1782 assert_eq!(app.focus(), Some(Item::NoiseMode));
1783 press(&mut app, "G j j");
1784 assert_eq!(app.focus(), Some(Item::Open(Page::Earbuds)));
1785 press(&mut app, "g g");
1786 assert_eq!(app.focus(), Some(Item::NoiseMode));
1787 }
1788
1789 #[test]
1790 fn rows_open_pages_and_the_cursor_starts_over() {
1791 let mut app = app();
1792 press(&mut app, "G G h");
1793 assert_eq!(app.page, Page::Overview);
1794 press(&mut app, "l");
1795 assert_eq!(app.page, Page::Earbuds);
1796 assert_eq!(app.cursor, 0);
1797 assert!(app.cursor_shown);
1798 }
1799
1800 #[test]
1801 fn pages_by_key() {
1802 let mut app = app();
1803 press(&mut app, "J");
1804 assert_eq!(app.page, Page::Equalizer);
1805 press(&mut app, "K K");
1806 assert_eq!(app.page, Page::Settings);
1807 press(&mut app, "1");
1808 assert_eq!(app.page, Page::Overview);
1809 press(&mut app, "g t");
1810 assert_eq!(app.page, Page::Equalizer);
1811 press(&mut app, "g T");
1812 assert_eq!(app.page, Page::Overview);
1813 press(&mut app, "9");
1814 assert_eq!(app.page, Page::Overview);
1815 }
1816
1817 #[test]
1818 fn esc_and_clicks_hide_the_cursor() {
1819 let mut app = app();
1820 press(&mut app, "j j");
1821 assert!(app.focus().is_some());
1822 press(&mut app, "Esc");
1823 assert_eq!(app.focus(), None);
1824 press(&mut app, "j");
1825 let _ = app.update(Message::Pointer);
1826 assert_eq!(app.focus(), None);
1827 // It comes back where it was.
1828 press(&mut app, "j");
1829 assert_eq!(app.focus(), Some(Item::AncLevel));
1830 }
1831
1832 #[test]
1833 fn the_list_of_keys_closes_on_any_key() {
1834 let mut app = app();
1835 press(&mut app, "?");
1836 assert!(app.help);
1837 press(&mut app, "j");
1838 assert!(!app.help);
1839 assert_eq!(app.focus(), None);
1840 press(&mut app, "?");
1841 let _ = app.update(Message::CloseHelp);
1842 assert!(!app.help);
1843 }
1844
1845 #[test]
1846 fn live_buds_are_read_first() {
1847 let (mut app, commands) = live();
1848 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1849 assert_eq!(app.link, Link::Connecting);
1850 assert!(app.items().is_empty(), "nothing to move over before the first reading");
1851 // A second refresh while the read is on its way sends nothing.
1852 press(&mut app, "r");
1853 assert!(commands.try_recv().is_err());
1854
1855 let _ = app.update(Message::Worker(Event::Found("realme Buds T310".to_owned())));
1856 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1857 assert_eq!(app.link, Link::Live);
1858 assert_eq!(app.buds.name, "realme Buds T310");
1859 assert!(app.buds.connected);
1860 assert_eq!(app.items().len(), 11);
1861 }
1862
1863 #[test]
1864 fn focus_tries_again_after_a_failure() {
1865 let (mut app, commands) = live();
1866 let _ = commands.try_recv();
1867 let _ = app.update(Message::Worker(Event::Failed {
1868 kind: Kind::NotFound,
1869 message: "realme Buds T310 is paired but not connected".to_owned(),
1870 }));
1871 let _ = app.update(Message::Focused);
1872 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1873 assert_eq!(app.link, Link::Connecting);
1874 // A fresh reading is not read again on focus.
1875 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1876 let _ = app.update(Message::Focused);
1877 assert!(commands.try_recv().is_err());
1878 }
1879
1880 #[test]
1881 fn the_preset_in_use_is_not_written_again() {
1882 let (mut app, commands) = live();
1883 let _ = commands.try_recv();
1884 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1885 let _ = app.update(Message::Preset(4));
1886 assert!(commands.try_recv().is_err(), "Custom1 is in use already");
1887 let _ = app.update(Message::Preset(3));
1888 assert!(matches!(commands.try_recv(), Ok(Command::Eq(name)) if name == "pure-bass"));
1889 }
1890
1891 #[test]
1892 fn live_writes_wait_for_the_buds() {
1893 let (mut app, commands) = live();
1894 let _ = commands.try_recv();
1895 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1896
1897 let _ = app.update(Message::Switch("game", true));
1898 assert!(matches!(commands.try_recv(), Ok(Command::Switch("game", true))));
1899 assert!(!switch(&app, "game"), "the switch changes when the buds say so");
1900 assert_eq!(app.pending(), [Item::Switch("game")]);
1901 // While it is on its way, the same switch takes no other write.
1902 press(&mut app, "j j j Enter");
1903 assert!(commands.try_recv().is_err());
1904
1905 let game = Switch {
1906 key: "game",
1907 label: "Game mode",
1908 on: true,
1909 };
1910 let _ = app.update(Message::Worker(Event::Switch(game)));
1911 assert!(switch(&app, "game"));
1912 assert!(app.pending().is_empty());
1913
1914 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1915 assert!(matches!(
1916 commands.try_recv(),
1917 Ok(Command::Noise(NoiseMode::Transparency))
1918 ));
1919 assert_eq!(mode(&app), "anc");
1920 let now = NoiseControl {
1921 mode: "transparency".to_owned(),
1922 level: None,
1923 };
1924 let _ = app.update(Message::Worker(Event::Noise(now)));
1925 assert_eq!(mode(&app), "transparency");
1926 }
1927
1928 #[test]
1929 fn failures_say_why_and_clear_the_write() {
1930 let (mut app, commands) = live();
1931 let _ = app.update(Message::Worker(Event::Failed {
1932 kind: Kind::Busy,
1933 message: "control channel busy".to_owned(),
1934 }));
1935 assert!(matches!(app.link, Link::Failed { kind: Kind::Busy, .. }));
1936 assert!(app.problem().is_none(), "the Overview says it, not a banner");
1937
1938 let _ = commands.try_recv();
1939 let _ = app.update(Message::Refresh);
1940 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1941 assert_eq!(app.link, Link::Connecting);
1942 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1943
1944 let _ = app.update(Message::Switch("dual", false));
1945 let _ = app.update(Message::Worker(Event::Failed {
1946 kind: Kind::NotFound,
1947 message: "the buds closed the control link".to_owned(),
1948 }));
1949 assert!(app.pending().is_empty());
1950 assert!(switch(&app, "dual"));
1951 assert!(!app.buds.connected);
1952 assert!(app.problem().is_some());
1953 }
1954
1955 #[test]
1956 fn presets_and_bands_by_key() {
1957 let mut app = app();
1958 press(&mut app, "J");
1959 assert_eq!(app.page, Page::Equalizer);
1960 press(&mut app, "j l");
1961 assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
1962 assert_eq!(app.buds.eq_name(), "Pure raw");
1963 // Custom1's pencil, after the four built-in presets and Custom1's row, opens its bands.
1964 press(&mut app, "j j j j j Enter");
1965 assert_eq!(app.editing, Some(4));
1966 assert_eq!(
1967 app.buds.status.eq.as_deref(),
1968 Some("pure-raw"),
1969 "opening selects nothing"
1970 );
1971 press(&mut app, "G");
1972 assert_eq!(app.focus(), Some(Item::Band(4, 16000)));
1973 // Moving a band saves the preset, which puts it in use.
1974 press(&mut app, "l");
1975 assert_eq!(app.buds.status.eq.as_deref(), Some("custom-4"));
1976 let last = |app: &App| app.buds.preset(4).unwrap().bands.last().unwrap().gain;
1977 assert_eq!(last(&app), 3);
1978 press(&mut app, "l l l l l");
1979 assert_eq!(last(&app), 6, "it stops at the top of the range");
1980 }
1981
1982 #[test]
1983 fn band_moves_wait_for_the_write_before_them() {
1984 let (mut app, commands) = live();
1985 let _ = commands.try_recv();
1986 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1987 let _ = app.update(Message::EditPreset(4));
1988 press(&mut app, "J G G l");
1989 let gain = |bands: &[EqBand]| bands.last().map(|band| band.gain);
1990 assert!(matches!(
1991 commands.try_recv(),
1992 Ok(Command::CustomEq(name, bands)) if name == "custom-4" && gain(&bands) == Some(3)
1993 ));
1994 // While that write is on its way, moves only change the screen.
1995 press(&mut app, "l l");
1996 assert!(commands.try_recv().is_err());
1997 assert_eq!(app.draft.as_ref().and_then(|draft| gain(&draft.bands)), Some(5));
1998 // The answer comes: the latest bands go out at once.
1999 let mut saved = buds::demo().preset(4).unwrap().clone();
2000 if let Some(band) = saved.bands.last_mut() {
2001 band.gain = 3;
2002 }
2003 let _ = app.update(Message::Worker(Event::CustomEq(saved.clone())));
2004 assert!(matches!(
2005 commands.try_recv(),
2006 Ok(Command::CustomEq(_, bands)) if gain(&bands) == Some(5)
2007 ));
2008 assert_eq!(app.pending(), [Item::Preset(4)]);
2009 // A failure drops the moves: the screen shows what the buds kept.
2010 let _ = app.update(Message::Worker(Event::Failed {
2011 kind: Kind::Failed,
2012 message: "no reply".to_owned(),
2013 }));
2014 assert_eq!(app.draft, None);
2015 assert_eq!(gain(&app.buds.preset(4).unwrap().bands), Some(3));
2016 }
2017
2018 fn cycle(app: &App) -> Vec<String> {
2019 app.buds.touch.as_ref().unwrap().hold_cycle.clone().unwrap()
2020 }
2021
2022 #[test]
2023 fn gestures_and_the_hold_cycle_by_key() {
2024 let mut app = app();
2025 press(&mut app, "3 j j");
2026 assert_eq!(app.page, Page::Gestures);
2027 assert_eq!(app.focus(), Some(Item::Gesture(1)));
2028 press(&mut app, "l");
2029 assert_eq!(app.buds.action("left", "triple-tap"), Some("next"));
2030 press(&mut app, "h h h");
2031 assert_eq!(
2032 app.buds.action("left", "triple-tap"),
2033 Some("none"),
2034 "it stops at the first"
2035 );
2036
2037 press(&mut app, "G k l");
2038 assert_eq!(app.focus(), Some(Item::Cycle(1)));
2039 assert_eq!(cycle(&app), ["off", "anc", "transparency"]);
2040 press(&mut app, "h");
2041 assert_eq!(cycle(&app), ["off", "anc"]);
2042 press(&mut app, "j h");
2043 assert_eq!(cycle(&app), ["off", "anc"], "a hold needs two modes");
2044 }
2045
2046 #[test]
2047 fn gesture_picks_wait_for_the_write_before_them() {
2048 let (mut app, commands) = live();
2049 let _ = commands.try_recv();
2050 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2051 press(&mut app, "3 j j l");
2052 assert!(matches!(
2053 commands.try_recv(),
2054 Ok(Command::Touch { side, gesture, action })
2055 if side == "left" && gesture == "triple-tap" && action == "next"
2056 ));
2057 // While that write is on its way, picks only change the screen.
2058 press(&mut app, "l l");
2059 assert!(commands.try_recv().is_err());
2060 assert_eq!(app.gesture_action(1).as_deref(), Some("volume-up"));
2061 // The answer comes: the latest pick goes out at once.
2062 let mut table = reading().touch.unwrap().gestures;
2063 table[1].action = "next".to_owned();
2064 let _ = app.update(Message::Worker(Event::Touch(table)));
2065 assert!(matches!(
2066 commands.try_recv(),
2067 Ok(Command::Touch { action, .. }) if action == "volume-up"
2068 ));
2069 assert_eq!(app.pending(), [Item::Gesture(1)]);
2070 }
2071
2072 #[test]
2073 fn ringing_needs_a_yes() {
2074 let mut app = app();
2075 press(&mut app, "4 j j Enter");
2076 assert_eq!(app.page, Page::Earbuds);
2077 assert!(app.confirm_find);
2078 press(&mut app, "Esc");
2079 assert!(!app.confirm_find);
2080 assert!(!app.ringing);
2081 press(&mut app, "Enter j");
2082 assert!(!app.ringing, "only Enter says yes");
2083 press(&mut app, "Enter Enter");
2084 assert!(app.ringing);
2085 // Stopping asks nothing.
2086 press(&mut app, "Enter");
2087 assert!(!app.confirm_find);
2088 assert!(!app.ringing);
2089 }
2090
2091 #[test]
2092 fn live_ringing_waits_for_the_buds() {
2093 let (mut app, commands) = live();
2094 let _ = commands.try_recv();
2095 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2096 let _ = app.update(Message::Find);
2097 let _ = app.update(Message::ConfirmFind(true));
2098 assert!(matches!(commands.try_recv(), Ok(Command::Find(true))));
2099 assert!(!app.ringing);
2100 let _ = app.update(Message::Worker(Event::Ringing(true)));
2101 assert!(app.ringing);
2102 let _ = app.update(Message::Find);
2103 assert!(matches!(commands.try_recv(), Ok(Command::Find(false))));
2104 }
2105
2106 #[test]
2107 fn the_bass_level_by_key() {
2108 let mut app = app();
2109 press(&mut app, "j j j j j j");
2110 assert_eq!(app.focus(), Some(Item::Bass), "under bass boost, on the Overview");
2111 assert_eq!(app.buds.bass, None, "the buds do not say");
2112 press(&mut app, "l");
2113 assert_eq!(app.buds.bass, Some(0), "the first step starts from the middle");
2114 press(&mut app, "l l");
2115 assert_eq!(app.buds.bass, Some(2));
2116 press(&mut app, "h h h h h h h h");
2117 assert_eq!(app.buds.bass, Some(-5), "it stops at the bottom");
2118 }
2119
2120 #[test]
2121 fn bass_moves_wait_for_the_level_before_them() {
2122 let (mut app, commands) = live();
2123 let _ = commands.try_recv();
2124 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2125 let _ = app.update(Message::Bass { level: 2, save: false });
2126 assert!(
2127 commands.try_recv().is_err(),
2128 "a drag sends nothing until the mouse lets go"
2129 );
2130 let _ = app.update(Message::SaveBass);
2131 assert!(matches!(commands.try_recv(), Ok(Command::Bass(2))));
2132 let _ = app.update(Message::Bass { level: 4, save: true });
2133 assert!(commands.try_recv().is_err());
2134 let _ = app.update(Message::Worker(Event::Bass(2)));
2135 assert!(matches!(commands.try_recv(), Ok(Command::Bass(4))));
2136 let _ = app.update(Message::Worker(Event::Bass(4)));
2137 assert_eq!(app.buds.bass, Some(4));
2138 assert_eq!(app.bass_draft, None);
2139 assert!(commands.try_recv().is_err());
2140 }
2141
2142 #[test]
2143 fn presets_are_created_and_renamed() {
2144 let mut app = app();
2145 let _ = app.update(Message::NewPreset);
2146 assert!(app.prompt.is_some());
2147 // Typing goes to the field, not to the cursor.
2148 press(&mut app, "j j");
2149 assert_eq!(app.focus(), None);
2150 let _ = app.update(Message::NameInput(" Gym".to_owned()));
2151 let _ = app.update(Message::SubmitName);
2152 assert!(app.prompt.as_ref().is_some_and(|p| p.problem.is_some()));
2153 let _ = app.update(Message::NameInput("Custom1".to_owned()));
2154 let _ = app.update(Message::SubmitName);
2155 assert!(app.prompt.as_ref().is_some_and(|p| p.problem.is_some()), "taken");
2156 let _ = app.update(Message::NameInput("Gym".to_owned()));
2157 let _ = app.update(Message::SubmitName);
2158 assert_eq!(app.prompt, None);
2159 assert_eq!(app.buds.eq_name(), "Gym", "a new preset is put to use");
2160 let gym = app
2161 .buds
2162 .custom_names()
2163 .find(|(_, name)| *name == "Gym")
2164 .map(|(id, _)| id)
2165 .unwrap();
2166 assert_eq!(app.editing, Some(gym), "and its bands open");
2167
2168 let _ = app.update(Message::RenamePreset(gym));
2169 assert_eq!(app.prompt.as_ref().map(|p| p.text.as_str()), Some("Gym"));
2170 let _ = app.update(Message::NameInput("Run".to_owned()));
2171 let _ = app.update(Message::SubmitName);
2172 assert_eq!(app.buds.preset(gym).map(|p| p.name.as_str()), Some("Run"));
2173
2174 let _ = app.update(Message::RenamePreset(gym));
2175 press(&mut app, "Esc");
2176 assert_eq!(app.prompt, None);
2177
2178 // With every custom preset gone, a new one still gets bands.
2179 for id in [4, gym] {
2180 let _ = app.update(Message::DeletePreset(id));
2181 let _ = app.update(Message::ConfirmDelete(true));
2182 }
2183 assert_eq!(app.buds.custom_names().count(), 0);
2184 let _ = app.update(Message::NewPreset);
2185 let _ = app.update(Message::NameInput("Fresh".to_owned()));
2186 let _ = app.update(Message::SubmitName);
2187 let fresh = equalizer::in_use(&app.buds).unwrap();
2188 assert_eq!((fresh.name.as_str(), fresh.bands.len()), ("Fresh", 6));
2189 }
2190
2191 #[test]
2192 fn live_presets_are_created_and_renamed_by_the_buds() {
2193 let (mut app, commands) = live();
2194 let _ = commands.try_recv();
2195 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2196 let _ = app.update(Message::NewPreset);
2197 let _ = app.update(Message::NameInput("Gym".to_owned()));
2198 let _ = app.update(Message::SubmitName);
2199 assert!(matches!(commands.try_recv(), Ok(Command::CreateEq(name)) if name == "Gym"));
2200 assert_eq!(app.pending(), [Item::NewPreset]);
2201 let mut presets = buds::demo().eq.unwrap();
2202 let mut gym = presets.presets.last().unwrap().clone();
2203 gym.id = 5;
2204 gym.name = "Gym".to_owned();
2205 presets.presets.push(gym);
2206 presets.current = Some("custom-5".to_owned());
2207 let _ = app.update(Message::Worker(Event::Presets(presets)));
2208 assert_eq!(app.buds.eq_name(), "Gym");
2209 assert_eq!(app.editing, Some(5));
2210 assert!(app.pending().is_empty());
2211
2212 let _ = app.update(Message::RenamePreset(4));
2213 let _ = app.update(Message::NameInput("Run".to_owned()));
2214 let _ = app.update(Message::SubmitName);
2215 assert!(matches!(
2216 commands.try_recv(),
2217 Ok(Command::RenameEq(preset, name)) if preset == "custom-4" && name == "Run"
2218 ));
2219 }
2220
2221 #[test]
2222 fn presets_are_deleted_after_a_yes() {
2223 let mut app = app();
2224 let _ = app.update(Message::DeletePreset(4));
2225 assert_eq!(app.confirm_delete, Some(4));
2226 press(&mut app, "j");
2227 assert_eq!(app.confirm_delete, None, "any other key says no");
2228 assert!(app.buds.preset(4).is_some());
2229 let _ = app.update(Message::DeletePreset(4));
2230 press(&mut app, "Enter");
2231 assert!(app.buds.preset(4).is_none());
2232 assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
2233
2234 let (mut app, commands) = live();
2235 let _ = commands.try_recv();
2236 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2237 let _ = app.update(Message::DeletePreset(4));
2238 let _ = app.update(Message::ConfirmDelete(true));
2239 assert!(matches!(commands.try_recv(), Ok(Command::DeleteEq(preset)) if preset == "custom-4"));
2240 assert_eq!(app.pending(), [Item::Preset(4)]);
2241 }
2242
2243 #[test]
2244 fn the_pencil_opens_the_bands_and_done_closes_them() {
2245 let mut app = app();
2246 press(&mut app, "J");
2247 let bands = |app: &App| app.items().iter().any(|item| matches!(item, Item::Band(..)));
2248 assert!(!bands(&app));
2249 let _ = app.update(Message::EditPreset(4));
2250 assert!(bands(&app));
2251 let _ = app.update(Message::DoneEditing);
2252 assert!(!bands(&app));
2253 // The pencil again closes them too.
2254 let _ = app.update(Message::EditPreset(4));
2255 let _ = app.update(Message::EditPreset(4));
2256 assert!(!bands(&app));
2257 }
2258
2259 #[test]
2260 fn dual_connection_asks_before_going_off() {
2261 let mut app = app();
2262 press(&mut app, "4 j");
2263 assert_eq!(app.focus(), Some(Item::Switch("dual")), "first on Earbud settings");
2264 press(&mut app, "Enter");
2265 assert!(app.confirm_dual);
2266 press(&mut app, "Esc");
2267 assert!(!app.confirm_dual);
2268 assert!(switch(&app, "dual"));
2269 press(&mut app, "Enter Enter");
2270 assert!(!switch(&app, "dual"));
2271 // Turning it on asks nothing.
2272 press(&mut app, "Enter");
2273 assert!(!app.confirm_dual);
2274 assert!(switch(&app, "dual"));
2275
2276 let (mut app, commands) = live();
2277 let _ = commands.try_recv();
2278 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2279 let _ = app.update(Message::Switch("dual", false));
2280 assert!(commands.try_recv().is_err());
2281 let _ = app.update(Message::ConfirmDual(true));
2282 assert!(matches!(commands.try_recv(), Ok(Command::Switch("dual", false))));
2283 }
2284
2285 #[test]
2286 fn steps() {
2287 let abc = ['a', 'b', 'c'];
2288 assert_eq!(step(&abc, Some(0), Action::Left), None);
2289 assert_eq!(step(&abc, Some(0), Action::Right), Some('b'));
2290 assert_eq!(step(&abc, Some(2), Action::Right), None);
2291 assert_eq!(step(&abc, Some(2), Action::Activate), Some('a'));
2292 assert_eq!(step(&abc, None, Action::Left), Some('a'));
2293 }
2294
2295 #[test]
2296 fn arguments() {
2297 let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned()));
2298 assert!(matches!(args(&[]), Ok(Args::Run(Options { demo: false, .. }))));
2299 assert!(matches!(args(&["--demo"]), Ok(Args::Run(Options { demo: true, .. }))));
2300 assert!(matches!(args(&["--debug"]), Ok(Args::Run(Options { debug: true, .. }))));
2301 assert!(matches!(
2302 args(&["--theme", "x.json"]),
2303 Ok(Args::Run(Options { theme: Some(_), .. }))
2304 ));
2305 assert!(matches!(
2306 args(&["--device", "88:0E:85:46:FE:5C"]),
2307 Ok(Args::Run(Options { device: Some(_), .. }))
2308 ));
2309 assert!(matches!(args(&["--help"]), Ok(Args::Help)));
2310 assert!(args(&["--theme"]).is_err());
2311 assert!(args(&["--device", "nope"]).is_err());
2312 assert!(args(&["--bogus"]).is_err());
2313 }
2314
2315 /// Renders every page to `target/gui-snapshots/`, so the look can be checked without a
2316 /// display. `BUDCTL_THEME=<file>` renders with that palette.
2317 #[test]
2318 fn pages_render() {
2319 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
2320 let _ = fs::remove_dir_all(&dir);
2321 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
2322 let on = |page: Page, keys: &str| {
2323 let mut app = App::new(palette.clone(), None, true);
2324 app.page = page;
2325 press(&mut app, keys);
2326 app
2327 };
2328 let mut shots: Vec<(String, f32, App)> = Page::ALL
2329 .into_iter()
2330 .map(|page| (format!("{page:?}").to_lowercase(), 800.0, on(page, "")))
2331 .collect();
2332 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
2333 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
2334 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
2335 let mut editing = on(Page::Equalizer, "");
2336 let _ = editing.update(Message::EditPreset(4));
2337 press(&mut editing, "G G k k");
2338 shots.push(("equalizer-cursor".to_owned(), 1100.0, editing));
2339 shots.push(("equalizer-pencil".to_owned(), 800.0, on(Page::Equalizer, "j j j j j j")));
2340 shots.push(("gestures-cursor".to_owned(), 1000.0, on(Page::Gestures, "j j")));
2341 shots.push(("find-confirm".to_owned(), 800.0, on(Page::Earbuds, "j j Enter")));
2342 shots.push((
2343 "overview-bass".to_owned(),
2344 1340.0,
2345 on(Page::Overview, "j j j j j j l l"),
2346 ));
2347 let mut naming = on(Page::Equalizer, "");
2348 let _ = naming.update(Message::RenamePreset(4));
2349 let _ = naming.update(Message::NameInput("Custom1 ".to_owned()));
2350 let _ = naming.update(Message::SubmitName);
2351 shots.push(("name-prompt".to_owned(), 800.0, naming));
2352 shots.push(("equalizer-builtin".to_owned(), 1000.0, on(Page::Equalizer, "j l")));
2353 let mut dual = on(Page::Earbuds, "j");
2354 let _ = dual.update(Message::Switch("dual", false));
2355 shots.push(("dual-confirm".to_owned(), 800.0, dual));
2356 let mut deleting = on(Page::Equalizer, "");
2357 let _ = deleting.update(Message::DeletePreset(4));
2358 shots.push(("delete-confirm".to_owned(), 800.0, deleting));
2359 let mut connecting = App::new(palette.clone(), None, false);
2360 connecting.link = Link::Connecting;
2361 shots.push(("connecting".to_owned(), 800.0, connecting));
2362 let mut busy = App::new(palette.clone(), None, false);
2363 busy.answer(Event::Failed {
2364 kind: Kind::Busy,
2365 message: "control channel busy: is another app connected?".to_owned(),
2366 });
2367 shots.push(("busy".to_owned(), 800.0, busy));
2368 let (mut saving, _commands) = live();
2369 saving.answer(Event::Read(Box::new(reading())));
2370 saving.answer(Event::Failed {
2371 kind: Kind::Failed,
2372 message: "the buds did not answer: no reply to command 0x0403".to_owned(),
2373 });
2374 let _ = saving.update(Message::Switch("spatial", false));
2375 let _ = saving.update(Message::Noise(NoiseMode::Off));
2376 shots.push(("saving".to_owned(), 1240.0, saving));
2377 render(&dir, shots);
2378 }
2379
2380 /// The README's screenshots: the demo buds in the built-in palette, written to
2381 /// `docs/screenshots/` by `cargo test -p budctl-gui readme_screenshots -- --ignored`.
2382 #[test]
2383 #[ignore = "writes into docs/; run it when the look changes"]
2384 fn readme_screenshots() {
2385 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
2386 let scratch = root.join("target/readme-screenshots");
2387 let _ = fs::remove_dir_all(&scratch);
2388 let on = |page: Page, keys: &str| {
2389 let mut app = App::new(None, None, true);
2390 app.page = page;
2391 press(&mut app, keys);
2392 app
2393 };
2394 let mut equalizer = on(Page::Equalizer, "");
2395 let _ = equalizer.update(Message::EditPreset(4));
2396 let shots = vec![
2397 ("overview".to_owned(), 1140.0, on(Page::Overview, "")),
2398 ("equalizer".to_owned(), 900.0, equalizer),
2399 ("gestures".to_owned(), 840.0, on(Page::Gestures, "")),
2400 ("earbuds".to_owned(), 800.0, on(Page::Earbuds, "")),
2401 ("keys".to_owned(), 800.0, on(Page::Overview, "?")),
2402 ];
2403 let names: Vec<String> = shots.iter().map(|(name, _, _)| name.clone()).collect();
2404 render(&scratch, shots);
2405 let out = root.join("docs/screenshots");
2406 fs::create_dir_all(&out).unwrap();
2407 for name in names {
2408 fs::rename(
2409 scratch.join(format!("{name}-tiny-skia.png")),
2410 out.join(format!("{name}.png")),
2411 )
2412 .unwrap();
2413 }
2414 }
2415
2416 /// Renders each (name, window height, app) 1040 px wide, to `<dir>/<name>-tiny-skia.png`.
2417 fn render(dir: &Path, shots: Vec<(String, f32, App)>) {
2418 for (name, height, app) in shots {
2419 let settings = iced::Settings {
2420 default_font: theme::UI,
2421 fonts: theme::FONTS.into_iter().map(Into::into).collect(),
2422 ..iced::Settings::default()
2423 };
2424 let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
2425 let snapshot = ui.snapshot(&app.theme).unwrap();
2426 assert!(snapshot.matches_image(dir.join(name)).unwrap());
2427 }
2428 }
2429}