Krolyxon
Commit 449c3e4

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

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