Krolyxon
Commit 7b28e7d

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

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